Personnaliser ses styles css et Twitter Bootstrap avec Less

Personnaliser son site construit avec un framework (Twitter Bootstrap par ex.) est rapidement indispensable pour se démarquer de la multitude de sites qui utilisent le même framework avec le style par défaut. Utiliser les bons outils permet d’adapter rapidement les styles à l’identité visuelle du client.

Le framework Twitter Bootstrap – celui avec lequel on travaille – peut être téléchargé sous sa forme non compilée, il se compose alors d’un ensemble de fichiers en language Less qui permet la génération dynamique de feuilles de style. Les fichiers d’extension .less fournis surchargent la syntaxe css et ajoutent les mécanismes de variables, d’imbrication, de mixins, opérateurs et fonctions. Voir Wikipédia.

Le but de ce document n’est pas d’être exhaustif, il ne couvrira que le nécessaire pour générer les feuilles de style d’une application. Le sujet est très documenté, on trouve rapidement de bons articles, et le seul objectif est de conserver une trace du cheminement suivi en interne sur mes applications.

Installation du pré-processeur Less

Less tourne à l’intérieur de Node.js, qui est un moteur d’exécution JavaScript construit sur le moteur JavaScript de Chrome. Ce dernier permet d’installer Less à l’aide d’une simple ligne de commande. (Notez qu’il existe également des applications tierces qui permettent de compiler les .less)

Installez Node.js à l’aide d’un exécutable (sous Windows).

Installez Less depuis le gestionnaire de paquets de Node.js

npm install -g less

Installez l’extension clean-css qui permet de rapetisser (minify) les styles css générés.

npm install -g less-plugin-clean-css

Vous pouvez vous référer à la documentation de démarrage rapide sous Less

Nous nous intéresserons uniquement à l’usage en ligne de commande, un fichier de style est généré par défaut sur la sortie standard depuis un fichier .less, ou bien dans le fichier fourni en paramètre.

lessc styles.less styles.css

Ou bien version rapetissée (avec les options de compatibilité par défaut)

lessc --clean-css styles.less styles.min.css

Structure du framework css

Bootstrap fait usage de Less pour tout paramétrer, jusqu’aux propriétés de la grille css qui sont construites à partir de variables et fonctions mathématiques.

Tout est basé sur des variables, toutes stockées dans un seul endroit. Cela permet à Bootstrap d’être construit sur un ensemble de données hautement personnalisable.

J’utilise un thème acheté en ligne qui exploite Twitter Bootstrap, il est organisé de façon à pouvoir recompiler Bootstrap pour personnalisation, avec des fichiers sources. Les fichiers less sont sous le répertoire \web\vendor\bootstrap\less\ qui contient tous les fichiers source .less organisés en briques à importer depuis le fichier d’indexe : bootstrap.less

less

Ce fichier est le point d’entrée sur lequel on pourrait lancer la commande de compilation, vous pouvez voir comment il commence par importer les variables puis les mixins et ensuite toutes les autres briques qui composent le framework. Bien entendu on peut supprimer de ce fichier tout ce qui n’est pas utile à la mise en page de l’application pour alléger le fichier de style final, on peut également personnaliser chaque brique, à commencer par les variables !

Je ne vais pas personnaliser Twitter Bootstrap à partir des fichiers du framework, comme j’utilise un thème qui surcharge Bootstrap il me faut modifier le thème et celui ci n’est fourni qu’en css, c’est bien dommage. Qu’à cela ne tienne, il suffit de créer un fichier de surcharge .less pour commencer à travailler avec des variables, à commencer par les couleurs.

Sous le répertoire /web/less je créé un fichier override.less qui importe un mélangeur (mixins) qui a pour finalité d’apporter une fonctionnalité de boucle.

lessc override.less ../styles/override.css
lessc style.less ../styles/style.css

Le fichier override.css est importé en dernier dans les codes sources de l’application.

Le contenu du fichier override.css permet de boucler sur des valeurs textuelles pour générer des classes.

lessc bootstrap.less ../dist/css/bootstrap-custom.css

Less opérations courantes !

Ce document ne se destine pas à des designers ni des développeurs front-end, non, on veut seulement l’essentiel pour les opérations de façade sur des applications à public restreint.

Jouer avec les fonctions de couleurs et les opérateurs nous est particulièrement utile

Pour les couleurs, on peut les manipuler de base avec ces fonctions :

lighten(@color, 10%); // renvoie une couleur 10% plus claire que @color
darken(@color, 10%); // renvoie une couleur 10% plus foncée que @color
saturate(@color, 10%); // renvoie une couleur 10% plus saturée que @color // renvoie une couleur 10% moins saturée que @color
fadein(@color, 10%); // renvoie une couleur 10% moins transparente que @color
fadeout(@color, 10%); // renvoie une couleur 10% plus transparente que @color
fade(@color, 50%); // renvoie @color avec 50% de transparence
spin(@color, 10); // renvoie une couleur qui a une couleur décalée de 10° dans le spectre par rapport à @color
spin(@color, -10); // renvoie une couleur qui a une couleur décalée de -10° dans le spectre par rapport à @color
mix(@color1, @color2); // renvoie un mélange de @color1 et @color2

hue(@color);        // renvoie la teinte de @color
saturation(@color); // renvoie la saturation de @color
lightness(@color);  // renvoie la luminosité de @color
alpha(@color);      // renvoie la transparence de @color

@new: hsl(hue(@old), 45%, 90%); // Cree une nouvelle couleur depuis une autre couleur

Les opérateurs peuvent affecter une couleur ou bien un unité de taille. Quelques exemples :

@coldgray: #b9b5a9;
@lightgray: @coldgray + #333;
@darkgray: @coldgray - #333;
@border: 1px;
.box1 {
	border-top: @border ;
	border-bottom: @border * 2;
}

Ces exemple sont tirés d’un bon article en français que je vous invite à consulter.

Au delà des couleurs et des tailles, il faut pouvoir générer des styles en boucle, cela peut se faire en important des mélangeurs dédiés à cela.
Pour boucler en less, voir un exemple de for-each

Les mélangeurs (fichier for.less à importer) :

// ............................................................
// .for

.for(@i, @n) {.-each(@i)}
.for(@n)     when (isnumber(@n)) {.for(1, @n)}
.for(@i, @n) when not (@i = @n)  {
    .for((@i + (@n - @i) / abs(@n - @i)), @n);
}

// ............................................................
// .for-each

.for(@array)   when (default()) {.for-impl_(length(@array))}
.for-impl_(@i) when (@i > 1)    {.for-impl_((@i - 1))}
.for-impl_(@i) when (@i > 0)    {.-each(extract(@array, @i))}

Avec ces constructions il devient possible de générer des classes css pour des listes d’éléments ! Regardez l’exemple ci dessous : d’abord on défini une liste, puis une couleur nommée pour chaque élément de la liste. Deux mélangeurs permettent de générer les classes à appliquer à des boutons, finalement le dernier appel boucle sur la liste d’éléments pour construire les classes.

@import "for";

@list: primary info success warning warning2 danger;

@primary: #ac1c72;
@info: lighten(#18417f, 10%);
@success: #62cb31;
@warning: #ffb606;
@danger: red;
@container: #edf0f5;

.button-background-color (@background-color) {
    background-color: @background-color;
    border-color: @background-color;
}

.button-color (@background-color, @color: inherit) {
    .button-background-color (@background-color);
    color: @color;
}

.for(@list); .-each(@name) {
    .btn-@{name} {
        .button-color(@@name, #FFFFFF);
    }
    .btn-@{name}:hover, .btn-@{name}:focus, .btn-@{name}:active, .btn-@{name}.active, .open .dropdown-toggle.btn-@{name} {
        .button-color(lighten(@@name, 10%), #FFFFFF);
    }
    //...
}

Au final il nous sera utile d’arrondir les angles sur des boutons et des conteneurs, le mieux est d’utiliser une librairie de mélangeurs prêts à l’emploi.

La librairie la plus complète est sans doute Less Hat 3, à télécharger et utiliser librement, la collection la plus simple est sans doute Less elements.

Quelques exemples tirés de Less Hat pour finir :

div {
 .border-radius(5px);
}
div {
 .border-top-left-radius(5px);
}
div {
 .box-shadow(0 1px 10px rgba(20,20,20,0.5));
}
div {
 .background-image(linear-gradient(to bottom, #fb83fa 0%,#e93cec 100%));
}

YaLinqo port de .NET 4 LINQ vers PHP – Par l’exemple

Here, you will find a few ports of Microsoft Developer Network’s 101 LINQ Samples using YaLinqo library, a LINQ port for PHP.

Restriction Operators [where],
Projection Operators [select],
Partitioning Operators [take, skip, takewhile, skipwhile],
Ordering Operators [orderby, orderbydescending, thenby],
Grouping Operators [groupby],
Set Operators [distinct, union, intersect, except],
Conversion Operators [toarray, tolist, todictionnary, oftype],
Element Operators [first,firstordefault,elementat],
Quantifiers [any, all],
Aggregate Operators [count, sum, min, max, average, aggregate],
Join Operators [join, groupjoin]

YaLinqo par l’exemple – Introduction

LINQ (Language Integrated Query) est un langage de requête qui étend la syntaxe des langages C# et Visual Basic, c’est un outil à la fois puissant et élégant que l’on aimerai utiliser en dehors de ces langages.

Plusieurs tentatives ont été faites pour porter ces possibilités sur le langage PHP, parmi lesquelles une assez réussie, qui apporte beaucoup d’agréments au développeur car elle est assez intuitive si l’on a utilisé LINQ. Elle est bien documentée pour l’auto-complétion des méthodes dans l’IDE, et n’impacte pas trop les performances par rapport au confort d’utilisation et aux gains sur la souplesse et la maintenabilité des codes sources.

Ce document est un guide pratique, un tutoriel à l’utilisation de la librairie YaLinqo à laquelle il manque des exemples pratiques. Dans un premier temps j’ai repris certains des exemples d’utilisation de LINQ par Microsoft, ceux ci peuvent servir de référence. Certains exemples sont redondants, et je ne les ai pas repris, d’autres exemples démontrent plus cruellement les limitations de la librairie et je n’ai pas pu les transcrire, soit parce-que ce n’est pas possible, soit parce-que je n’ai pas compris et pris le temps de comprendre comment utiliser la librairie pour ces cas d’utilisation.

Au delà de ces exemples simples nous verrons des cas pratiques sur une structure de données plus complexe. Je vous montrerai comment utiliser la librairie pour exploiter les métadonnées de vos collections de photographies et collections musicales extraites à partir de la librairie GetID3.

Faites attention à respecter l’emploi du caractère simple ‘ et non pas du double « , depuis les exemples C# pensez à doubler ==>, préfixer vos variables par un $, ou encore utiliser -> pour vos appels de méthodes.

Installation

composer require athari/yalinqo ~2.0

Pour exécuter certains de ces exemples vous aurez besoin de quelques données produit très simples !

Définitions

class Product
{

    private $id;
    private $unitsInStock = 0;
    private $productName = '';
    private $unitPrice = 0;
    private $category;

    function getId()
    {
        return $this->id;
    }

    function getUnitsInStock()
    {
        return $this->unitsInStock;
    }

    function getProductName()
    {
        return $this->productName;
    }

    function getUnitPrice()
    {
        return $this->unitPrice;
    }

    function getCategory()
    {
        return $this->category;
    }

    function __construct($id, $productName, $unitsInStock, $unitPrice, $category)
    {
        $this->id = $id;
        $this->productName = $productName;
        $this->unitsInStock = (int) $unitsInStock;
        $this->unitPrice = (int) $unitPrice;
        $this->category = $category;
    }

}

$products = [
    new Product(1, "Chef Anton's Gumbo Mix", 0, 2, 'meat'),
    new Product(2, "Alice Mutton", 0, 5, 'meat'),
    new Product(3, "Rostbratwurst", 5, 3, 'meat'),
    new Product(4, "Gorgonzola Telino", 6, 6, 'cheese'),
    new Product(5, "Ikura", 0, 3, 'fish'),
    new Product(6, "Teatime Chocolate Biscuits", 2, 3, 'dessert'),
    new Product(7, "Chartreuse verte", 0, 2, 'fuel'),
    new Product(8, "Camembert Pierrot", 2, 7, 'cheese'),
];

Restriction Operators

Description

Where – Simple 1
This sample uses where to find all elements of an array less than 5

$numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0];

$lowNums = from($numbers)
        ->where('$n ==> $n < 5') ->toList();

Resultat

Array
(
    [0] => 4
    [1] => 1
    [2] => 3
    [3] => 2
    [4] => 0
)

Where – Simple 2
This sample uses where to find all products that are out of stock

$soldOutProducts = from($products)
        ->where('$p ==> $p->getUnitsInStock() == 0')
        ->toList();

Resultat

Array
(
    [0] => Product Object
        (
            [id:Product:private] => 1
            [unitsInStock:Product:private] => 0
            [productName:Product:private] => Chef Anton's Gumbo Mix
            [unitPrice:Product:private] => 2
            [category:Product:private] => meat
        )

    [1] => Product Object
        (
            [id:Product:private] => 2
            [unitsInStock:Product:private] => 0
            [productName:Product:private] => Alice Mutton
            [unitPrice:Product:private] => 5
            [category:Product:private] => meat
        )

    [2] => Product Object
        (
            [id:Product:private] => 5
            [unitsInStock:Product:private] => 0
            [productName:Product:private] => Ikura
            [unitPrice:Product:private] => 3
            [category:Product:private] => fish
        )

    [3] => Product Object
        (
            [id:Product:private] => 7
            [unitsInStock:Product:private] => 0
            [productName:Product:private] => Chartreuse verte
            [unitPrice:Product:private] => 2
            [category:Product:private] => fuel
        )

)

Where – Simple 3
This sample uses where to find all products that are in stock and cost more than 3.00 per unit

$expensiveInStockProducts = from($products)
        ->where('$p ==> ($p->getUnitsInStock() > 0 && $p->getUnitPrice() > 3) ')
        ->toList();

Resultat

Array
(
    [0] => Product Object
        (
            [id:Product:private] => 4
            [unitsInStock:Product:private] => 6
            [productName:Product:private] => Gorgonzola Telino
            [unitPrice:Product:private] => 6
            [category:Product:private] => cheese
        )

    [1] => Product Object
        (
            [id:Product:private] => 8
            [unitsInStock:Product:private] => 2
            [productName:Product:private] => Camembert Pierrot
            [unitPrice:Product:private] => 7
            [category:Product:private] => cheese
        )

)

Where – Indexed
This sample demonstrates an indexed Where clause that returns digits whose name is shorter than their value

$digits = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];

$shortDigits = from($digits)
        ->where('$v, $k ==> strlen($v) < $k') ->toArray();

Resultat

Array
(
    [5] => five
    [6] => six
    [7] => seven
    [8] => eight
    [9] => nine
)

Projection Operators

Description
Select – Simple 1
This sample uses select to produce a sequence of ints one higher than those in an existing array of ints

$numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0];

$numsPlusOne = from($numbers)
        ->select('$n ==> $n + 1')
        ->toList();

Resultat

Array
(
    [0] => 6
    [1] => 5
    [2] => 2
    [3] => 4
    [4] => 10
    [5] => 9
    [6] => 7
    [7] => 8
    [8] => 3
    [9] => 1
)

Select – Simple 2
This sample uses select to return a sequence of just the names of a list of products

$productNames = from($products)
        ->select('$p ==> $p->getProductName()')
        ->toList();

Resultat

Array
(
    [0] => Chef Anton's Gumbo Mix
    [1] => Alice Mutton
    [2] => Rostbratwurst
    [3] => Gorgonzola Telino
    [4] => Ikura
    [5] => Teatime Chocolate Biscuits
    [6] => Chartreuse verte
    [7] => Camembert Pierrot
)

Select – Anonymous Types 1
This sample uses select to produce a sequence of the uppercase and lowercase versions of each word in the original array

$words = [ "aPPLE", "BlUeBeRrY", "cHeRry"];

$upperLowerWords = from($words)
        ->select('$w ==> (object)array("upper" => strtoupper($w), "lower" => strtolower($w))')
        ->toList();

Resultat

Array
(
    [0] => stdClass Object
        (
            [upper] => APPLE
            [lower] => apple
        )

    [1] => stdClass Object
        (
            [upper] => BLUEBERRY
            [lower] => blueberry
        )

    [2] => stdClass Object
        (
            [upper] => CHERRY
            [lower] => cherry
        )

)

Partitioning Operators

Description
Take – Simple
This sample uses Take to get only the first 3 elements of the array

$numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0];

$first3Numbers = from($numbers)
        ->take(3)
        ->toList();

Resultat

Array
(
    [0] => 5
    [1] => 4
    [2] => 1
)

Skip – Simple
This sample uses Skip to get all but the first 4 elements of the array

$allButFirst4Numbers = from($numbers)
        ->skip(4)
        ->toList();

Resultat

Array
(
    [0] => 9
    [1] => 8
    [2] => 6
    [3] => 7
    [4] => 2
    [5] => 0
)

TakeWhile – Simple
This sample uses TakeWhile to return elements starting from the beginning of the array until a number is hit that is not less than 6

$firstNumbersLessThan6 = from($numbers)
        ->takeWhile('$n ==> $n < 6') ->toList();

Resultat

Array
(
    [0] => 5
    [1] => 4
    [2] => 1
    [3] => 3
)

TakeWhile – Indexed
This sample uses TakeWhile to return elements starting from the beginning of the array until a number is hit that is less than its position in the array

$firstSmallNumbers = from($numbers)
        ->takeWhile('$n, $index ==> $n >= $index')
        ->toList();

Resultat

Array
(
    [0] => 5
    [1] => 4
)

SkipWhile – Simple
This sample uses SkipWhile to get the elements of the array starting from the first element divisible by 3

$allButFirst3Numbers = from($numbers)
        ->skipWhile('$n ==> $n % 3 != 0')
        ->toList();

Resultat

Array
(
    [0] => 3
    [1] => 9
    [2] => 8
    [3] => 6
    [4] => 7
    [5] => 2
    [6] => 0
)

Ordering Operators

Description
OrderBy – Simple 1
This sample uses orderby to sort a list of words alphabetically

$words = [ "cherry", "apple", "blueberry"];
$sortedWords = from($words)
        ->orderBy('$v')
        ->toList();

Resultat

Array
(
    [0] => apple
    [1] => blueberry
    [2] => cherry
)

OrderBy – Simple 2
This sample uses orderby to sort a list of words by length

$sortedWords = from($words)
        ->orderBy('$v ==> strlen($v)')
        ->select('$v ==> [$v => strlen($v)]')
        ->toList();

Resultat

Array
(
    [0] => Array
        (
            [apple] => 5
        )

    [1] => Array
        (
            [cherry] => 6
        )

    [2] => Array
        (
            [blueberry] => 9
        )

)

OrderBy – Simple 3
This sample uses orderby to sort a list of products by name

$sortedProducts = from($products)
        ->orderBy('$p ==> $p->getProductName()')
        ->select('$p ==> $p->getProductName()')
        ->toList();

Resultat

Array
(
    [0] => Alice Mutton
    [1] => Camembert Pierrot
    [2] => Chartreuse verte
    [3] => Chef Anton's Gumbo Mix
    [4] => Gorgonzola Telino
    [5] => Ikura
    [6] => Rostbratwurst
    [7] => Teatime Chocolate Biscuits
)

OrderBy – Comparer
This sample uses an OrderBy clause with a custom comparer to do a case-insensitive sort of the words in an array

$sortedProducts = from($products)
        ->orderBy('$p ==> $p', function ($a, $b) {
            return $a->getUnitPrice() > $b->getUnitPrice();
        })
        ->select('$p ==> [$p->getProductName() => $p->getUnitPrice()]')
        ->toList();

Resultat

Array
(
    [0] => Array
        (
            [Chef Anton's Gumbo Mix] => 2
        )

    [1] => Array
        (
            [Chartreuse verte] => 2
        )

    [2] => Array
        (
            [Teatime Chocolate Biscuits] => 3
        )

    [3] => Array
        (
            [Ikura] => 3
        )

    [4] => Array
        (
            [Rostbratwurst] => 3
        )

    [5] => Array
        (
            [Alice Mutton] => 5
        )

    [6] => Array
        (
            [Gorgonzola Telino] => 6
        )

    [7] => Array
        (
            [Camembert Pierrot] => 7
        )

)

OrderByDescending – Simple 1
This sample uses orderby and descending to sort a list of doubles from highest to lowest

$doubles = [ 1.7, 2.3, 1.9, 4.1, 2.9];

$sortedDoubles = from($doubles)
        ->orderByDescending()
        ->toList();

Resultat

Array
(
    [0] => 4.1
    [1] => 2.9
    [2] => 2.3
    [3] => 1.9
    [4] => 1.7
)

ThenBy – Simple
This sample uses a compound orderby to sort a list of digits, first by length of their name, and then alphabetically by the name itself

$words = [ "aPPLE", "AbAcUs", "bRaNcH", "BlUeBeRrY", "ClOvEr", "cHeRry"];

$sortedWords = from($words)
        ->orderBy('$w ==> strlen($w)')
        ->thenBy('$w ==> strtoupper($w)')
        ->toList();

Resultat

Array
(
    [0] => aPPLE
    [1] => AbAcUs
    [2] => bRaNcH
    [3] => cHeRry
    [4] => ClOvEr
    [5] => BlUeBeRrY
)

Grouping Operators

Description
GroupBy – Simple 1
This sample uses group by to partition a list of numbers by their remainder when divided by 5

$numberGroups = from($numbers)
        ->groupBy('$n ==> $n % 5') 
        ->toList();

Resultat

Array
(
    [0] => Array
        (
            [0] => 5
            [1] => 0
        )

    [1] => Array
        (
            [0] => 4
            [1] => 9
        )

    [2] => Array
        (
            [0] => 1
            [1] => 6
        )

    [3] => Array
        (
            [0] => 3
            [1] => 8
        )

    [4] => Array
        (
            [0] => 7
            [1] => 2
        )

)

GroupBy – Simple 2
This sample uses group by to partition a list of words by their first letter

$words = [ "blueberry", "chimpanzee", "abacus", "banana", "apple", "cheese"];

$wordGroups = from($words)
        ->groupBy('$w ==> $w[0]')
        ->select('$v, $k ==> (object)["firstLetter" => $k, "words" => $v]')
        ->toArray();

Resultat

Array
(
    [b] => stdClass Object
        (
            [firstLetter] => b
            [words] => Array
                (
                    [0] => blueberry
                    [1] => banana
                )

        )

     => stdClass Object
        (
            [firstLetter] => c
            [words] => Array
                (
                    [0] => chimpanzee
                    [1] => cheese
                )

        )

    [a] => stdClass Object
        (
            [firstLetter] => a
            [words] => Array
                (
                    [0] => abacus
                    [1] => apple
                )

        )

)

GroupBy – Simple 3
This sample uses group by to partition a list of products by category

$orderGroups = from($products)
        ->groupBy('$p ==> $p->getCategory()')
        ->select('$v, $k ==> (object)["category" => $k, "products" => $v]')
        ->toArray();

Resultat

Array
(
    [meat] => stdClass Object
        (
            [category] => meat
            [products] => Array
                (
                    [0] => Product Object
                        (
                            ...
                        )

                    [1] => Product Object
                        (
                            ...
                        )

                    [2] => Product Object
                        (
                            ...
                        )

                )

        )

    [cheese] => stdClass Object
        (
            [category] => cheese
            [products] => Array
                (
                    [0] => Product Object
                        (
                            ...
                        )

                    [1] => Product Object
                        (
                            ...
                        )

                )

        )

    [fish] => stdClass Object
        (
            [category] => fish
            [products] => Array
                (
                    [0] => Product Object
                        (
                            ...
                        )

                )

        )

    [dessert] => stdClass Object
        (
            [category] => dessert
            [products] => Array
                (
                    [0] => Product Object
                        (
                            ...
                        )

                )

        )

    [fuel] => stdClass Object
        (
            [category] => fuel
            [products] => Array
                (
                    [0] => Product Object
                        (
                            ...
                        )

                )

        )

)

Set Operators

Description

Distinct – 1
This sample uses Distinct to remove duplicate elements in a sequence of factors of 300

$factorsOf300 = [ 2, 2, 3, 5, 5];

$uniqueFactors = from($factorsOf300)->distinct()->toList();

Resultat

Array
(
    [0] => 2
    [1] => 3
    [2] => 5
)

Distinct – 2
This sample uses Distinct to find the unique Category names

$categoryNames = from($products)
        ->select('$p ==> $p->getCategory()')
        ->distinct()
        ->toList();

Resultat

Array
(
    [0] => meat
    [1] => cheese
    [2] => fish
    [3] => dessert
    [4] => fuel
)

Union – 1
This sample uses Union to create one sequence that contains the unique values from both arrays

$numbersA = [ 0, 2, 4, 5, 6, 8, 9];
$numbersB = [ 1, 3, 5, 7, 8];

$uniqueNumbers = from($numbersA)
        ->union($numbersB)
        ->toList();

Resultat

Array
(
    [0] => 0
    [1] => 2
    [2] => 4
    [3] => 5
    [4] => 6
    [5] => 8
    [6] => 9
    [7] => 1
    [8] => 3
    [9] => 7
)

Intersect – 1
This sample uses Intersect to create one sequence that contains the common values shared by both arrays

$numbersA = [ 0, 2, 4, 5, 6, 8, 9];
$numbersB = [ 1, 3, 5, 7, 8];

$commonNumbers = from($numbersA)
        ->intersect($numbersB)
        ->toList();

Resultat

Array
(
    [0] => 5
    [1] => 8
)

Except – 1
This sample uses Except to create a sequence that contains the values from numbersAthat are not also in numbersB

$aOnlyNumbers = from($numbersA)
        ->except($numbersB)
        ->toList();

Resultat

Array
(
    [0] => 0
    [1] => 2
    [2] => 4
    [3] => 6
    [4] => 9
)

Conversion Operators

Description
ToArray vs ToList
This sample uses ToArray to immediately evaluate a sequence into an array and into a list (notice indices)

$doubles = [ 1.7, 2.3, 1.9, 4.1, 2.9];
$sortedDoubles = from($doubles)
        ->orderByDescending();

print_r($sortedDoubles->toArray());
print_r($sortedDoubles->toList());

Resultat

Array
(
    [3] => 4.1
    [4] => 2.9
    [1] => 2.3
    [2] => 1.9
    [0] => 1.7
)
Array
(
    [0] => 4.1
    [1] => 2.9
    [2] => 2.3
    [3] => 1.9
    [4] => 1.7
)

ToDictionary
This sample uses ToDictionary to immediately evaluate a sequence and a related key expression into a dictionary

$orderRecords = from($products)
        ->toDictionary('$p ==> $p->getProductName()', '$p ==> $p->getCategory()');

Resultat

Array
(
    [Chef Anton's Gumbo Mix] => meat
    [Alice Mutton] => meat
    [Rostbratwurst] => meat
    [Gorgonzola Telino] => cheese
    [Ikura] => fish
    [Teatime Chocolate Biscuits] => dessert
    [Chartreuse verte] => fuel
    [Camembert Pierrot] => cheese
)

OfType
This sample uses OfType to return only the elements of the array that are of a given type

$numbers = [ null, 1.0, "two", 3, "four", 5, "six", 7.0, $products[0], new \stdClass()];
# Eyes bleeding ? Actually I extended this sample to other types that doesn't look like numbers...

$ints = from($numbers)->ofType('int')->toList();
$floats = from($numbers)->ofType('float')->toList();
$products = from($numbers)
        ->ofType('object')
        ->where('$o ==> $o instanceof Product')
        ->toList();

print_r($ints);
print_r($floats);
print_r($products);

Resultat

Array
(
    [0] => 3
    [1] => 5
)
Array
(
    [0] => 1
    [1] => 7
)
ProductArray
(
    [0] => Product Object
        (
            [id:Product:private] => 1
            [unitsInStock:Product:private] => 0
            [productName:Product:private] => Chef Anton's Gumbo Mix
            [unitPrice:Product:private] => 2
            [category:Product:private] => meat
        )

)

Element Operators

Description
First – Simple
This sample uses First to return the first matching element as a Product, instead of as a sequence containing a Product

$product4 = from($products)
        ->where('$p ==> $p->getId() == 4')
        ->first();

Resultat

Product Object
(
    [id:Product:private] => 4
    [unitsInStock:Product:private] => 6
    [productName:Product:private] => Gorgonzola Telino
    [unitPrice:Product:private] => 6
    [category:Product:private] => cheese
)

First – Condition
This sample uses First to find the first element in the array that starts with ‘o’

$strings = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];

$startsWithO = from($strings)
        ->first('$s ==> $s[0] == "o"');

Resultat

one

FirstOrDefault – Simple
This sample uses FirstOrDefault to try to return the first element of the sequence, unless there are no elements, in which case the default value for that type is returne

$firstNumOrDefault = from($numbers)->firstOrDefault();

Resultat

0

FirstOrDefault – Condition
This sample uses FirstOrDefault to return the first product whose ProductID is 789 as a single Product object, unless there is no match, in which case null is returned

$product789 = from($products)
        ->firstOrDefault(null, '$p ==> $p->getId() == 789');

Resultat

null

ElementAt
This sample uses ElementAt to retrieve the second number greater than 5 from an array

$numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0];

$fourthLowNum = from($numbers)
        ->where('$n ==> $n > 5')
        ->toValues()
        ->elementAt(1);

Resultat

8

Quantifiers

Description
Any – Simple
This sample uses Any to determine if any of the words in the array contain the substring ‘ei’

$words = [ "believe", "relief", "receipt", "field"];

$iAfterE = from($words)
        ->any('$w ==> (false !== strrpos($w, "ei"))');

Resultat

true

All – Simple
This sample uses All to determine whether an array contains only odd numbers

$numbers = [ 1, 11, 3, 19, 41, 65, 19];

$onlyOdd = from($numbers)
        ->all('$n ==> $n % 2 == 1');

Resultat

true

All – Grouped
This sample uses All to return a grouped a list of products only for categories that have all of their products in stock
REM. Best way to do that with YaLinqo PHP ? code becomes hard to read

$productGroups = from($products)
        ->groupBy('$p ==> $p->getCategory()')
        ->toArray();

$productGroups = from($productGroups)
        ->where('$g,$k ==> from($g)->all(\'$p ==> $p->getUnitsInStock() > 0\')')
        ->toArray();

Resultat

Array
(
    [cheese] => Array
        (
            [0] => Product Object
                (
                    [id:Product:private] => 4
                    [unitsInStock:Product:private] => 6
                    [productName:Product:private] => Gorgonzola Telino
                    [unitPrice:Product:private] => 6
                    [category:Product:private] => cheese
                )

            [1] => Product Object
                (
                    [id:Product:private] => 8
                    [unitsInStock:Product:private] => 2
                    [productName:Product:private] => Camembert Pierrot
                    [unitPrice:Product:private] => 7
                    [category:Product:private] => cheese
                )

        )

    [dessert] => Array
        (
            [0] => Product Object
                (
                    [id:Product:private] => 6
                    [unitsInStock:Product:private] => 2
                    [productName:Product:private] => Teatime Chocolate Biscuits
                    [unitPrice:Product:private] => 3
                    [category:Product:private] => dessert
                )

        )

)

Aggregate Operators

Description
Count – Simple
This sample uses Count to get the number of unique factors of 300

$factorsOf300 = [ 2, 2, 3, 5, 5];

$uniqueFactors = from($factorsOf300)->distinct()->count();

Resultat

3

Count – Conditional
This sample uses Count to get the number of odd ints in the array

$numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0];

$oddNumbers = from($numbers)->count('$n ==> $n % 2 == 1');

Resultat

5

Count – Grouped
This sample uses Count to return a list of categories and how many products each has

$categoryCounts = from($products)
        ->groupBy('$p ==> $p->getCategory()')
        ->select('$v, $k ==> (object)["Category" => $k, "ProductCount" => from($v)->count()]')
        ->toList();

Resultat

Array
(
    [0] => stdClass Object
        (
            [Category] => meat
            [ProductCount] => 3
        )

    [1] => stdClass Object
        (
            [Category] => cheese
            [ProductCount] => 2
        )

    [2] => stdClass Object
        (
            [Category] => fish
            [ProductCount] => 1
        )

    [3] => stdClass Object
        (
            [Category] => dessert
            [ProductCount] => 1
        )

    [4] => stdClass Object
        (
            [Category] => fuel
            [ProductCount] => 1
        )

)

Sum – Simple
This sample uses Sum to get the total of the numbers in an array

$numSum = from($numbers)->sum();

Resultat

45

Sum – Projection
This sample uses Sum to get the total number of characters of all words in the array

$words = [ "cherry", "apple", "blueberry"];

$totalChars = from($words)->sum('$w ==> strlen($w)');

Resultat

20

Sum – Grouped
This sample uses Sum to get the total units in stock for each product category

$categories = from($products)
        ->groupBy('$p ==> $p->getCategory()')
        ->select('$v, $k ==> (object)["Category" => $k, "TotalUnitsInStock" => from($v)->sum(\'$p ==> $p->getUnitsInStock()\')]')
        ->toList();

Resultat

Array
(
    [0] => stdClass Object
        (
            [Category] => meat
            [TotalUnitsInStock] => 5
        )

    [1] => stdClass Object
        (
            [Category] => cheese
            [TotalUnitsInStock] => 8
        )

    [2] => stdClass Object
        (
            [Category] => fish
            [TotalUnitsInStock] => 0
        )

    [3] => stdClass Object
        (
            [Category] => dessert
            [TotalUnitsInStock] => 2
        )

    [4] => stdClass Object
        (
            [Category] => fuel
            [TotalUnitsInStock] => 0
        )

)

Min – Simple (min(), max() et average() sont interchangeables…)
This sample uses Min to get the lowest number in an array.

$minNum = from($numbers)->min();

Resultat

0

Min – Projection
This sample uses Min to get the length of the shortest word in an array

$shortestWord = from($words)->min('$w ==> strlen($w)');

Resultat

5

Min – Grouped
This sample uses Min to get the cheapest price among each category’s products

$categories = from($products)
        ->groupBy('$p ==> $p->getCategory()')
        ->select('$v, $k ==> (object)["Category" => $k, "CheapestPrice" => from($v)->min(\'$p ==> $p->getUnitPrice()\')]')
        ->toList();

Resultat

Array
(
    [0] => stdClass Object
        (
            [Category] => meat
            [CheapestPrice] => 2
        )

    [1] => stdClass Object
        (
            [Category] => cheese
            [CheapestPrice] => 6
        )

    [2] => stdClass Object
        (
            [Category] => fish
            [CheapestPrice] => 3
        )

    [3] => stdClass Object
        (
            [Category] => dessert
            [CheapestPrice] => 3
        )

    [4] => stdClass Object
        (
            [Category] => fuel
            [CheapestPrice] => 2
        )

)

Aggregate – Simple
This sample uses Aggregate to create a running product on the array that calculates the total product of all elements

$doubles = [ 1.7, 2.3, 1.9, 4.1, 2.9];

$product = from($doubles)
        ->aggregate('($runningProduct, $nextFactor) ==> $runningProduct * $nextFactor');

Resultat

88.33081

Aggregate – Seed
This sample uses Aggregate to create a running account balance that subtracts each withdrawal from the initial balance of 100, as long as the balance never drops below 0

$startBalance = 100.0;

$attemptedWithdrawals = [ 20, 10, 40, 50, 10, 70, 30];

$endBalance = from($attemptedWithdrawals)
        ->aggregate('($balance, $nextWithdrawal) ==> 
                (($nextWithdrawal <= $balance) ? ($balance - $nextWithdrawal) : $balance)', $startBalance);

Resultat

20

Join Operators

Description
Cross Join

$categories = [ "cherry", "apple", "blueberry", "cheese", "meat"];

$q = from($categories)
        ->join(
                from($products), '$c ==> $c', '$p ==> $p->getCategory()', '($c, $p) ==> array(
                    "category" => $c,
                    "product" => $p
                )'
        )
        ->toList();

Resultat

Array
(
    [0] => Array
        (
            [category] => cheese
            [product] => Product Object
                (
                    [id:Product:private] => 4
                    [unitsInStock:Product:private] => 6
                    [productName:Product:private] => Gorgonzola Telino
                    [unitPrice:Product:private] => 6
                    [category:Product:private] => cheese
                )

        )

    [1] => Array
        (
            [category] => cheese
            [product] => Product Object
                (
                    [id:Product:private] => 8
                    [unitsInStock:Product:private] => 2
                    [productName:Product:private] => Camembert Pierrot
                    [unitPrice:Product:private] => 7
                    [category:Product:private] => cheese
                )

        )

    [2] => Array
        (
            [category] => meat
            [product] => Product Object
                (
                    [id:Product:private] => 1
                    [unitsInStock:Product:private] => 0
                    [productName:Product:private] => Chef Anton's Gumbo Mix
                    [unitPrice:Product:private] => 2
                    [category:Product:private] => meat
                )

        )

    [3] => Array
        (
            [category] => meat
            [product] => Product Object
                (
                    [id:Product:private] => 2
                    [unitsInStock:Product:private] => 0
                    [productName:Product:private] => Alice Mutton
                    [unitPrice:Product:private] => 5
                    [category:Product:private] => meat
                )

        )

    [4] => Array
        (
            [category] => meat
            [product] => Product Object
                (
                    [id:Product:private] => 3
                    [unitsInStock:Product:private] => 5
                    [productName:Product:private] => Rostbratwurst
                    [unitPrice:Product:private] => 3
                    [category:Product:private] => meat
                )

        )

)

Group Join
Using a group join you can get all the products that match a given category bundled as a sequence
REM. Goup join performs left outer joins, see the results

$q = from($categories)
        ->groupJoin(
                from($products), '$c ==> $c', '$p ==> $p->getCategory()', '($c, $p) ==> array(
                    "category" => $c,
                    "products" => $p
                )'
        )
        ->toListDeep();

Resultat

Array
(
    [0] => Array
        (
            [0] => cherry
            [1] => Array
                (
                )

        )

    [1] => Array
        (
            [0] => apple
            [1] => Array
                (
                )

        )

    [2] => Array
        (
            [0] => blueberry
            [1] => Array
                (
                )

        )

    [3] => Array
        (
            [0] => cheese
            [1] => Array
                (
                    [0] => Product Object
                        (
                            [id:Product:private] => 4
                            [unitsInStock:Product:private] => 6
                            [productName:Product:private] => Gorgonzola Telino
                            [unitPrice:Product:private] => 6
                            [category:Product:private] => cheese
                        )

                    [1] => Product Object
                        (
                            [id:Product:private] => 8
                            [unitsInStock:Product:private] => 2
                            [productName:Product:private] => Camembert Pierrot
                            [unitPrice:Product:private] => 7
                            [category:Product:private] => cheese
                        )

                )

        )

    [4] => Array
        (
            [0] => meat
            [1] => Array
                (
                    [0] => Product Object
                        (
                            [id:Product:private] => 1
                            [unitsInStock:Product:private] => 0
                            [productName:Product:private] => Chef Anton's Gumbo Mix
                            [unitPrice:Product:private] => 2
                            [category:Product:private] => meat
                        )

                    [1] => Product Object
                        (
                            [id:Product:private] => 2
                            [unitsInStock:Product:private] => 0
                            [productName:Product:private] => Alice Mutton
                            [unitPrice:Product:private] => 5
                            [category:Product:private] => meat
                        )

                    [2] => Product Object
                        (
                            [id:Product:private] => 3
                            [unitsInStock:Product:private] => 5
                            [productName:Product:private] => Rostbratwurst
                            [unitPrice:Product:private] => 3
                            [category:Product:private] => meat
                        )

                )

        )

YaLinqo par l’exemple – Cas pratiques

Pour finir de vous convaincre ou pas de l’utilité de la librairie nous allons explorer deux cas pratiques.

Introduction

Les deux cas fonctionnent sur le même principe de base, et je ne cherches pas à réutiliser le code montré ici ni l’utiliser dans une application réelle, le code est destiné à démontrer l’utilité de manipuler des données via la librairie YaLinqo. Il n’est pas exploitable dans le sens où l’on ne prendra pas en considération la consommation mémoire, le temps d’indexation, la consommation processeur, les erreurs d’accès, les nombreux avertissements, les -trop- bonnes pratiques et tout ce qui prendrai trop de temps pour un simple exemple. Après cet avertissement angoissant !, nous allons construire un indexe de certains types de fichiers multimédia, pour ne pas ré-indexer à chaque fois le disque, l’indexe sera sérialisé dans un fichier en mode optimisation du développeur, le code sera factorisé entre les deux exemples également en mode optimisation du développeur.

L’objectif du premier code sera de parcourir des répertoires à la recherche d’images de type JPG et de sortir quelques statistiques sur ces images. Pour quoi faire ? Simplement parce que par excès d’étourderie je réimporte régulièrement plusieurs fois les mêmes cartes mémoires d’appareils photos, je duplique des répertoires entiers d’images pour les retravailler et j’oublie des répertoires entiers dupliqués dans mon disque dur… Alors je veux avoir des informations sur le nombre d’images en double, combien de place elles occupent sur le disque dur, évaluer le travail à faire et combien d’espace disque il me faudrait pour faire une sauvegarde des images uniques, organisées par date de prise de vue et appareil photographique.

Le second code permettra comme le premier de parcourir des répertoires, mais cette fois ci à la recherche de fichiers musicaux. Pour quoi faire ? Je suis très consommateur de musique en ligne, et je souhaiterai avoir quelques statistiques et quelques informations sur la musique que j’ai achetée, donc à priori les artistes et les styles que j’aime, pour découvrir d’autres artistes et d’autres musiques similaires. A cette fin j’ai repéré un service en ligne avec une API REST qui permet d’interroger une base de données musicale de plusieurs manières afin de retourner des résultats pertinents.

Un socle de code commun

Pour ces exemples les dépendances sont limitées. Nous utiliserons le serveur embarqué de PHP qui peut être lancé depuis les dernières versions de PHP depuis le répertoire du projet, en ligne de commande. Par ex. sur le port 8077 :

php -S localhost:8077

Le code utilisera un espace de noms, et nous utiliserons composer pour installer le composant Symfony2 Finder, la librairie GetID3 et la librairie YaLinqo. Votre fichier composer.json devrait ressembler à cela :

{
    "require": {
        "james-heinrich/getid3": "^1.9",
        "symfony/finder": "^3.0",
        "athari/yalinqo": "~2.0",
    }
}

Après installation (voir les commandes composer require ou composer update), il vous suffira d’inclure le fichier de chargement automatique des classes en début de programme pour exploiter les librairies tierces depuis l’application.

<?php
namespace MediaDiscovery\MediaIndex;

require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Finder\Finder;

Le composant Symfony2 Finder

Le composant Finder est très simple et l’on pourrait très bien s’en passer pour cet exemple ou le remplacer par un autre équivalent, son utilité est de simplifier la recherche de fichiers et répertoires grâce à une interface fluide très compréhensible, cette simplicité nous distrait moins de notre objectif.

Nous allons l’utiliser pour parcourir récursivement un répertoire du système de fichier à la recherche de fichiers dont le nom répond à l’opérateur de globalisation sur les extensions .jpg, .mp3 etc… les répertoires qui ne sont pas accessibles en lecture au processus seront ignorés.

Ce qui se résume aux instructions :

        $this->finder
                ->files()
                ->ignoreUnreadableDirs()
                ->in($dir)
                ->name("*.{{$extensions}}");

        foreach ($this->finder as $file)
...

La librairie GetID3 – Analyse de fichiers

Le plus important du travail d’indexation sera exécuté par une librairie tierce qui analyse un fichier donné pour construire une structure de données représentative. Le contenu de la structure de données n’est malheureusement pas prédictible simplement car il est variable en fonction extraites du fichier (comme la présence ou pas d’indications EXIF), et ce contenu peut être assez imposant. Pour consommer moins de mémoire vive et rendre la structure plus cohérente entre deux appels, nous ferons une extraction des champs qui nous intéressent.

Le contenu de la structure est consultable sur GitHub.

Pour l’analyse des fichiers, nous allons inclure la librairie dans un conteneur afin de l’isoler un peu. Notez qu’une partie de la librairie possède des méthodes statiques que l’on ne peut pas injecter en dépendances.

/**
 * Conteneur pour l'outil d'analyse de fichiers getID3
 */
class AnalyserWrapper
{

    private $analyzer;

    /**
     * Instancie la classe getID3 et paramètre le jeux de caractères
     */
    public function __construct()
    {
        $this->analyzer = new \getID3();
        $this->analyzer->encoding = 'UTF-8';
    }

    /**
     * Analyse un fichier au travers de la librairie getID3
     * 
     * @param string $file Chemin vers un fichier
     * @return array Structure de données variable
     */
    public function analyze($file)
    {
        $metadata = @$this->analyzer->analyze($file);
        \getid3_lib::CopyTagsToComments($metadata);     // Restructure les données pour améliorer la cohésion de la structure
        $metadata['md5'] = md5_file($file); // La somme de contrôle md5 est ajoutée à la structure pour vérification d'unicité des fichiers
        return $metadata;
    }

}

La partie du code qui va se charger de l’indexation sera commune aux deux exemples, il s’agit simplement d’une classe abstraite avec des méthodes pour parcourir un répertoire, extraire des données après une analyse par la classe GetID3, faire persister l’indexe ainsi créé. Les méthodes abstraites permettent d’indiquer quelles extensions de fichier parcourir, de déterminer si les données d’analyse d’un fichier sont exploitables, et d’extraire les informations pertinentes d’une analyse.

/**
 * Méthodes communes aux classes d'indexation
 */
abstract class Index
{

    private $analyzer;
    private $finder;
    protected $data = [];

    /**
     * Constructeur
     * 
     * @param \MediaDiscovery\MediaIndex\AnalyserWrapper $analyzer Conteneur pour l'outil d'analyse de fichiers getID3
     * @param Finder $finder Composant Symfony de recherche de fichiers et répertoires
     */
    public function __construct(AnalyserWrapper $analyzer, Finder $finder)
    {
        $this->analyzer = $analyzer;
        $this->finder = $finder;
    }

    /**
     * Parcours un répertoire pour analysee les fichiers média
     * 
     * @param string $dir Chemin vers un répertoire
     * @return \MediaDiscovery\MediaIndex\Index Interface fluide
     */
    public function scan($dir)
    {
        $this->finder
                ->files()
                ->ignoreUnreadableDirs()
                ->in($dir)
                ->name("*.{{$this->getFilesExtensions()}}");

        foreach ($this->finder as $file)
            if (!empty($file->getRealpath()))
                $this->postAnalyze($this->analyzer->analyze($file->getRealpath()));

        return $this;
    }

    /**
     * Aggrège les données dans l'indexe après analyse d'un fichier
     * 
     * @param array $metadata Structure de données variable
     */
    private function postAnalyze($metadata)
    {
        if ($this->isUsable($metadata))
            $this->data [] = $this->extract($metadata);
    }

    /**
     * Retourne la liste des extensions de fichier à analyser
     */
    abstract function getFilesExtensions();

    /**
     * Détermine si les données d'analyse sont exploitables
     */
    abstract function isUsable($metadata);

    /**
     * Extrait un sous ensemble des données d'analyse pertinentes
     */
    abstract function extract($metadata);

    /**
     * Sérialise l'indexe dans un fichier pour assurer la persistence des données (au plus simple)
     * 
     * @param string $filename Nom de fichier
     * @return \MediaDiscovery\MediaIndex\Index Interface fluide
     */
    public function persist($filename)
    {
        file_put_contents(sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename, serialize($this->data));
        return $this;
    }

    /**
     * Désérialise l'indexe depuis un fichier
     * 
     * @param string $filename Nom de fichier
     * @return \MediaDiscovery\MediaIndex\Index Interface fluide
     */
    public function load($filename)
    {
        $data = unserialize(@file_get_contents(sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename));
        if ($data)
        {
            $this->data = $data;
            return true;
        }
        return false;
    }

}

A partir de là on peut exploiter l’indexe pour obtenir des informations dont le seul but est de démontrer l’emploi de la librairie YaLinqo au travers de requêtes sur la structure de données.

class PhotoIndex extends Index
{

    function getFilesExtensions()
    {
        return 'jpg';
    }
    
    function isUsable($metadata) {
        return true; // il fallait au moins tout ça...
    }
    
    function extract($metadata)
    {
        return [
            "size"      => $metadata["filesize"],
            "path"      => $metadata["filepath"],
            "name"      => $metadata["filename"],
            "md5"       => $metadata["md5"],
            "width"     => isset($metadata["video"]["resolution_x"])
                            ? $metadata["video"]["resolution_x"] 
                            : "",
            "height"    => isset($metadata["video"]["resolution_y"])
                            ? $metadata["video"]["resolution_y"] 
                            : "",
            "camera"    => isset($metadata["jpg"]["exif"]["IFD0"]["Model"]) 
                            ? $metadata["jpg"]["exif"]["IFD0"]["Model"]
                            : "",
            "filectime" => filectime($metadata["filepath"] . DIRECTORY_SEPARATOR . $metadata["filename"]),
            "datetime"  => isset($metadata["jpg"]["exif"]["IFD0"]["DateTime"])
                            ? $metadata["jpg"]["exif"]["IFD0"]["DateTime"]
                            : "",
        ];
    }

    function getDuplicatesInfos() {
...
    }
    
}

On pourra appeler simplement l’indexation par :

set_time_limit(0);//25 * 60);

$finder = new Finder();
$analyzer = new AnalyserWrapper();


$photoindex = new PhotoIndex($analyzer, $finder);

if (true || !$photoindex->load('iphotos'))  // a true si on veut forcer la réindexation
    $photoindex
            ->scan('C:')
            ->scan('D:\Media')
            ->persist('iphotos');

header('Content-Type: text/plain; charset=UTF-8');

var_dump($photoindex->getDuplicatesInfos());

Bien… tout ce code pour arriver au moment crucial de remplir la méthode getDuplicatesInfos() – ou d’autres- avec des requêtes qui s’avèrent très pratiques. Par ex.

    function getDuplicatesInfos() {
        
        $duplicates = @from($this->data)
                ->groupBy('$d ==> $d[md5]')
                ->where('$v, $k ==> count($v) > 1')
                ->select('$v, $k ==> ['
                        . '"md5" => $k, '
                        . '"count" => count($v), '
                        . '"images" => from($v)->orderBy(\'$i ==> $i["filectime"]\')->toList(), '
                        . ']')
                ->toList();
        
        $size1 = @from($this->data)->sum('$a ==> $a["size"]');
        $size2 = from($duplicates)->sum('$a ==> $a["images"][0]["size"] * ($a["count"] - 1)');

        return [
            "Nombre d'images : " => @from($this->data)->count(),
            "Espace occupé par les images sur le disque dur : " => $size1 / 1000000000 . ' Go',
            "Nombre d'images en double : " => from($duplicates)->sum('$a ==> $a["count"] - 1'),
            "Espace occupé par les images en double sur le disque dur : " => $size2 / 1000000000 . ' Go',
        ];
        
    }

Cela fait beaucoup de code pour peu d’exemples, mais prochainement j’ajouterai de plus nombreux exemples sur le deuxième cas pratique où la structure de données est plus complexe et les statistiques plus exploitables.

Gestion des utilisateurs, groupes et roles sous Symfony 2

L’objectif de ce document est de synthétiser les informations pour la mise en place d’une gestion des utilisateurs sous Symfony 2, avec l’attribution de rôles à des groupes d’utilisateurs. Nous verrons comment gérer les permissions au niveau des utilisateurs, et -cerise sur le gateau- nous configurerons une connexion à un annuaire LDAP de test pour évaluer la possibilité d’importer des utilisateurs depuis l’annuaire après une connexion réussie.

Le résultat que l’on souhaite obtenir est un cadre général pour gérer les utilisateurs sur une application typique en backoffice, en particulier on souhaite l’interface d’administration qui permette les tâches de création, modification, désactivation d’utilisateurs et de groupes, ainsi que l’attribution de rôles aux groupes d’utilisateurs.

Groupes ou pas groupes ? Les recommandations sous Symfony2 sont d’utiliser l’héritage de rôle au niveau utilisateur lorsque la situation le permet car elle couvre la plupart des cas et a l’avantage d’être plus performante. Je préfère gérer les rôles au niveau des groupes par habitude de travailler avec des organismes très découpés administrativement, ces découpages en services sont plus facilement gérables en termes de groupes. De plus je préfère masquer le plus possible aux utilisateurs de l’administration la possibilité d’avoir des privilèges individuels, cela évite de gérer les cas d’exception d’utilisateurs privilégiés et permet de clarifier les règles fonctionnelles.

En images, le résultat d’une interface d’administration des utilisateurs et groupes une fois appliquée une mise en page minimaliste.

symfony2-admin-user
symfony2-user-group

Sous Symfony, l’authentification d’un utilisateur par la base de données passe par une entité utilisateur qui implémente une interface définie dans le composant de sécurité.

Pour les besoins de l’application nous pourrions créer notre propre entité utilisateur qui implémente les interfaces utilisateur (AdvancedUserInterface et UserInterface) de Symfony 2, c’est un choix efficace, cependant nous utiliserons une extension populaire qui gère le support des utilisateurs et s’intègre à plusieurs autres extensions très populaires, nous verrons comment personnaliser les classes utilisateur et groupe pour nos propres besoins, comment ajouter des champs et manipuler les entités pour migrer une structure existante par exemple.

L’extension FOSUserBundle ajoute le support pour un système de base de données utilisateur soutenu dans Symfony2. Il fournit les éléments nécessaires à la gestion complète de l’utilisateur au travers des tâches courantes telles que l’enregistrement d’un nouvel utilisateur ou encore la récupération de mot de passe oublié. Voyons comment installer, utiliser et étendre cette extension.

L’extension FOSUserBundle

Le composant de sécurité Symfony fournit un cadre de sécurité flexible qui permet entre autres de charger les utilisateurs d’une base de données, l’utilisateur récupéré est fourni au composant de sécurité pour l’authentification. C’est le scénario que nous allons détailler : les utilisateurs seront stockés à l’aide de l’extension FOSUserBundle au travers de l’ORM Doctrine, ils seront récupérés puis exploités lors du processus d’authentification puis pour la gestion des permissions.

Installation de l’extension

L’installation FOSUserBundle est bien documentée, je vais reprendre rapidement les différentes étapes et fournir quelques explications annexes pour regrouper les sources d’informations nécessaires au démarrage d’un projet.

Télécharger les fichiers source

Démarrez un nouveau projet, soit comme par le passé par l’outil de gestion des dépendances d’applications PHP composer, soit par l’installeur Symfony, comme l’indiquent les dernières recommandations pour la création d’un projet Symfony.

Commencez par exécuter l’utilitaire composer, la commande suivante ajoute une ligne au fichier composer.json où sont référencées les extensions du projet.

composer require friendsofsymfony/user-bundle "~2.0@dev"

Lancez la commande composer de mise à jour pour installer le composant…

composer update

Pensez à enregistrer l’extension dans le noyau de l’application…

<?php
// app/AppKernel.php

public function registerBundles()
{
    $bundles = array(
        // ...
        new FOS\UserBundle\FOSUserBundle(),
        // ...
    );
}

A ce point vous devez disposer des codes sources, vous pouvez vérifier la présence de classes User et Group sous le répertoire /vendor/friendsofsymfony/user-bundle/Model
Ces classes sont les fondements de notre gestion utilisateur, elles possèdent de nombreuses propriétés et méthodes. Il nous reste à définir nos propres classes d’utilisateur et de groupes qui implémentent ces classes : elles comporteront toute la logique métier spécifique à notre application aussi il est recommandé d’isoler ces fonctionnalités au niveau de notre propre logique applicative.

Si vous suivez les recommandations de bonnes pratiques vous devriez normalement disposer d’un seul bundle pour la logique de l’application, ce code n’étant pas destiné à être un distribuable nous allons suivre les bonnes pratiques et créer nos classes sous ce bundle applicatif.

Nous nous bornons à utiliser l’ORM Doctrine pour la persistence en base de données, configurons notre base de données si ça n’est pas déjà fait.

Configurer une base de données

Editez le fichiers config/parameters.yml et modifiez les paramètres pour s’adapter à votre système de gestion de base de données, à titre d’exemple j’utiliserai une base PostgreSQL.

Je fais pointer la configuration sur l’adresse de ma machine qui est valable dans tous les cas d’utilisation, que ce soit une commande de console Doctrine depuis mon IDE ou bien un accès depuis le serveur web.

parameters:
    database_driver: pdo_pgsql
#    database_host: 127.0.0.1
    database_host: 192.168.30.30
    database_port: 5432
    database_name: sandbox
    database_user: sandbox
    database_password: sandbox
    mailer_transport: smtp
    mailer_host: null
    mailer_user: null
    mailer_password: null
    secret: 10d423abcab44c15b3b76f4fb9a3c4117

Attention, le fichier config/parameters.yml est auto-généré par l’utilitaire composer et les paramètres sont écrasés par défaut en cas de mise à jour.

Pour éviter ce comportement éditez le fichier composer.json et mettez en commentaire la ligne de la section post-update : « Incenteev\\ParameterHandler\\ScriptHandler::buildParameters »

Remarquez l’entrée database_driver, vous devrez modifier le fichier config.yml où le pilote renseigné par défaut correspond est pdo_mysql

# Doctrine Configuration
doctrine:
    dbal:
        driver:   "%database_driver%"

Sous PostgreSQL vous pouvez créer un utilisateur sanbox et lui attribuer les permissions sur la base à créer

psql >
CREATE USER sandbox WITH PASSWORD 'sandbox';
CREATE DATABASE sandbox
  WITH OWNER = sandbox
       ENCODING = 'UTF8'
       TABLESPACE = pg_default;
GRANT ALL ON DATABASE sandbox TO sandbox;

Vous devrez éditer le fichier de configuration pg_hba.conf de votre serveur et ajouter les règles d’accès nécessaires.

vi /etc/postgresql/9.4/main/pg_hba.conf
host    all             all             192.168.30.30/32        md5
host    all             all             192.168.0.14/32        md5

L’information host permet d’indiquer que la tentative de connexion doit être de type TCP/IP, la première information all spécifie que toutes les bases de données sont acceptées, la seconde information all permet d’accepter tous les noms d’utilisateur de la base de données, enfin la plage d’adresses permet de spécifier les machines client acceptées, saisissez votre adresse IP (l’hôte), suivie du masque CIDR /32 (255.255.255.255) afin d’accepter une seule machine hôte, le dernier paramètre md5 indique la méthode d’authentification, vous devrez vous connecter par mot de passe crypté md5

Attention à configurer PostgreSQL pour écouter les connexions entrantes sur les adresses nécessaires, en développement on peut écouter toutes les adresses.

vi /etc/postgresql/9.4/main/postgresql.conf

listen_addresses = '*'

Créer nos propres classes utilisateur et groupe

Créons l’entité utilisateur, il suffit de créer une classe src/AppBundle/Entity/User.php avec le contenu par défaut indiqué dans la documentation dans le cas Doctrine ORM. Dans notre cas nous utilisons des groupes d’utilisateur, cette relation est crée dans l’entité utilisateur.

<?php
// src/AppBundle/Entity/User.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Group")
     * @ORM\JoinTable(name="fos_user_user_group",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
     * )
     */
    protected $groups;

    public function __construct()
    {
        parent::__construct();
        // your own logic
    }
}

Le bundle permet d’associer des groupes aux utilisateurs, ce qui permet simplement de regrouper des collections de rôles. Les rôles associés à un groupe seront attribués aux utilisateurs qui appartiennent au groupe.

Créons l’entité groupe

// src/AppBundle/Entity/Group.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\Group as BaseGroup;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_group")
 */
class Group extends BaseGroup
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
     protected $id;
}

Configurer le bundle

L’étape suivante consiste à configurer la sécurité. Dans l’immédiat recopiez l’exemple fourni dans la documentation pour le fichier security.yml en prenant soin de conserver l’entrée de pare-feu nommée dev qui permet de désactiver la sécurité pour l’outil de profilage, poursuivez avec les étapes 5, 6, et 7 de la documentation, nous reviendrons plus loin sur la sécurité.

Ces étapes ne posent pas de difficulté et ne nécessitent pas d’adaptation par défaut pour Doctrine ORM :

Editez le fichier de configuration pour ajouter la configuration spécifique à FOSUserBundle sous app/config/config.yml

# app/config/config.yml
fos_user:
    db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
    firewall_name: main
    user_class: AppBundle\Entity\User
    group:
        group_class: AppBundle\Entity\Group

Editez le fichier de routage pour importer les routes spécifiques à FOSUserBundle sous app/config/routing.yml (ici la configuration avancée permettra de désactiver les routes dont on a pas besoin, par défaut le documentation indique le fichier de routes global)

# app/config/routing.yml
fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"

fos_user_profile:
    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
    prefix: /profile

fos_user_register:
    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
    prefix: /register

fos_user_resetting:
    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
    prefix: /resetting

fos_user_change_password:
    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
    prefix: /profile

fos_user_group:
    resource: "@FOSUserBundle/Resources/config/routing/group.xml"
    prefix: /group

Tout cela est bien beau, mais à vrai dire c’est assez confus ! Quelles sont donc ces routes, que nous réservent-elles ?

Symfony 2 permet de visualiser et débuguer les routes, à cette fin il propose une commande qui va permettre de voir chaque route de l’application, idéal dans notre cas pour découvrir les possibilités du bundle.

console debug:router
[router] Current routes
 Name                              Method   Scheme Host Path                              
 ...     
 homepage                          ANY      ANY    ANY  /                                 
 fos_user_security_login           GET|POST ANY    ANY  /login                            
 fos_user_security_check           POST     ANY    ANY  /login_check                      
 fos_user_security_logout          GET      ANY    ANY  /logout                           
 fos_user_profile_show             GET      ANY    ANY  /profile/                         
 fos_user_profile_edit             GET|POST ANY    ANY  /profile/edit                     
 fos_user_registration_register    GET|POST ANY    ANY  /register/                        
 fos_user_registration_check_email GET      ANY    ANY  /register/check-email             
 fos_user_registration_confirm     GET      ANY    ANY  /register/confirm/{token}         
 fos_user_registration_confirmed   GET      ANY    ANY  /register/confirmed               
 fos_user_resetting_request        GET      ANY    ANY  /resetting/request                
 fos_user_resetting_send_email     POST     ANY    ANY  /resetting/send-email             
 fos_user_resetting_check_email    GET      ANY    ANY  /resetting/check-email            
 fos_user_resetting_reset          GET|POST ANY    ANY  /resetting/reset/{token}          
 fos_user_change_password          GET|POST ANY    ANY  /profile/change-password
...         
Done.

Le résultat est assez parlant, les routes sont définies pour le formulaire de connexion, le post du formulaire, la déconnection, la vue du profil utilisateur, l’édition du profil utilisateur, l’enregistrement d’un utilisateur, le changement de mot de passe, et ce qui semble être des méthodes de gestion interne.

Nous pouvons même investiguer d’avantage à l’aide d’une autre commande Symfony, demandons plus d’information sur le formulaire de connexion.

console router:match /login
Route "fos_user_security_login" matches

[router] Route "fos_user_security_login"
Name         fos_user_security_login
Path         /login
Path Regex   #^/login$#s
Host         ANY
Host Regex   
Scheme       ANY
Method       GET|POST
Class        Symfony\Component\Routing\Route
Defaults     _controller: FOSUserBundle:Security:login
Requirements NO CUSTOM
Options      compiler_class: Symfony\Component\Routing\RouteCompiler
Done.

Retenons pour le moment vers quel contrôleur pointe la route.

Il reste à mettre à jour le schéma de base de données pour prendre en compte notre classe utilisateur, cette opération va créer la table ainsi qu’une séquence associée

console doctrine:schema:update --force

Vous y êtes ? Vous pouvez désormais vérifier que vous êtes en mesure d’accéder au formulaire de login !

http://192.168.30.30/login

Bien, comment se connecter !? En toute logique il nous faut créer un premier utilisateur…

A ce moment là, la documentation officielle vous invite à consulter une liste de liens pour aller plus loin, et à vrai dire si vous êtes pressé, ce qui est probablement le cas si vous débutez un projet réel, c’est à ce moment là où vous serez tenté d’abandonner le bundle face à la montagne de travail qui se profile à l’horizon ! N’en faites rien, et voyons comment aller plus loin sans trop charger la mule. Mais d’abord il est temps d’expliquer le pourquoi de la configuration de sécurité qui nous est demandée lors de l’installation.

Mise en place du composant sécurité et formulaire d’authentification

La sécurité sous Symfony est un système qui permet de déterminer l’identité d’un utilisateur et de contrôler ses autorisations d’accès.

En ce qui concerne notre besoin le processus sécuritaire consiste à créer des règles de pare-feu pour le processus d’authentification par formulaire web et des règles de contrôles d’accès pour déterminer si l’utilisateur peut accéder aux ressources. Il faut donc notamment autoriser tout le monde à accéder au formulaire d’authentification.

Configurer le pare feu

Lorsqu’un utilisateur va chercher à accéder à l’application, le processus de sécurité se met en route : il fait correspondre l’url que l’utilisateur cherche à accéder avec les règles configurées dans le pare-feu pour déterminer si l’utilisateur doit être authentifié.

Une règle est composée d’un masque d’expression régulière et d’instructions qui permettent de déterminer si l’utilisateur doit ou ne doit pas être authentifié. Il y a donc mise en correspondance de l’url avec l’expression régulière pour savoir si la règle doit être vérifiée. Chaque règle est testée jusqu’à ce que le système trouve une correspondance ou qu’il ait parcouru l’ensemble des règles : la première correspondance établie est donc celle qui permet de déterminer si l’utilisateur doit être authentifié.

Sous Symfony2 la sécurité se configure dans le fichier app/config/security.yml, c’est l’un des fichiers que l’on vous a demandé de paramétrer pour FOSUserBundle.

Revoyons les règles de pare-feu :

# app/config/security.yml
security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            form_login:
                provider: fos_userbundle
                csrf_provider: security.csrf.token_manager # Use form.csrf_provider instead for Symfony <2.4

            logout:       true
            anonymous:    true

    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin/, role: ROLE_ADMIN }

Dans l’exemple précédent, nous définissons deux règles. Les règles sont évaluées par ordre de priorité.

  • La règle nommée dev permet de retirer le profileur web Symfony et les ressources du pare-feu lors de la phase de développement. Elle fait correspondre les url qui contiennent les mots _profiler ou _wdt et désactive la sécurité pour ces url
  • La règle main permet de faire correspondre toutes les urls, c’est le même pare-feu qui va sécuriser l’application et les routes définies dans le bundle. L’entrée form_login indique à Symfony de sécuriser l’accès par une authentification par formulaire, tout utilisateur qui n’est pas authentifié sera redirigé vers la page de connexion. Les chemins vers le formulaire de connexion sont en temps normaux indiqués dans le firewall par deux routes : la route /login qui affiche le formulaire, la route /login_check qui permet de valider le formulaire lorsqu’il est soumis par l’utilisateur, cependant avec l’utilisation du bundle il suffit de déclarer que le bundle est fournisseur de ce service, ce qui est réalisé par l’instruction de l’entrée provider.

Le système vérifie les droits d’accès aux ressources, ce qui peut se traduire par : le système vérifie par rapport au rôle utilisateur s’il est autorisé à accéder à l’url sur laquelle il tente de se rendre.

Le processus d’autorisation est paramétrable sur les urls, sous la section nommée access_control. Cette section access_control est l’endroit où vous spécifiez les informations d’identification nécessaires pour que les utilisateurs tentent d’accéder à des zones spécifiques de votre application.
Le pare-feu est configuré pour accepter les accès anonymes, et seul le role anonyme est requis pour les routes qui permettent à l’utilisateur d’accéder au formulaire de connexion et autres routes d’inscription, les requêtes qui débutent par /admin nécessiteront par contre de posséder le rôle ROLE_ADMIN

Lorsque l’utilisateur tente d’accéder à une page, le système de sécurité fait correspondre comme l’url que l’utilisateur cherche à accéder avec les règles configurées dans les contrôles d’accès.

Une règle est composée d’un masque d’expression régulière et d’instructions qui permettent de déterminer le rôle minimal requis pour accéder à la ressource.

Le rôle est récupéré depuis l’objet utilisateur de l’utilisateur authentifié, ou bien est renseigné à un rôle utilisateur anonyme sinon.

Pour finir, notez l’héritage de rôle permet à un rôle administrateur d’accéder aux ressources d’un rôle utilisateur, et au rôle super administrateur d’accéder par effet de cascade au ressources des rôles administrateur et utilisateur.

Le système de sécurité a connaissance des chemins vers le formulaire de connexion, vers la vérification effectuée après soumission du formulaire et vers la déconnexion au travers du bundle déclaré comme fournisseur. En fait le formulaire de connexion est la responsabilité de l’application (que l’on délègue à FOSUserBundle), sa soumission par contre est traitée par le système de sécurité. Cela implique de respecter une norme simple (par défaut) pour le nommage des éléments de formulaire, le composant de sécurité ne requiert que deux champs nommés par défaut _username et _password. Sachant cela vous pouvez ouvrir et étudier le formulaire de connexion, nous connaissons les routes, et nous avons vu précédemment quel contrôleur correspondait à la connexion.

Vérifiez par vous même, ouvrez le contrôleur puis le fichier de template correspondant au rendu : \vendor\friendsofsymfony\user-bundle\Resources\views\Security\login.html.twig

Vous pouvez également jeter un œil au contrôleur qui s’occupe de la vérification utilisateur, il ne fait que jeter une exception car cette action est normalement interceptée par le système de sécurité.

console router:match /login_check --method POST

Le système de sécurité a également besoin de savoir comment sont encodés les mots de passe lorsqu’il fait la comparaison du mot de passe saisi dans le formulaire de connexion avec le mot de passe encodé récupéré dans l’objet utilisateur. C’est pourquoi il faut paramétrer l’encodeur par défaut qui sera utilisé avec l’entité utilisateur, ce qui est réalisé dans le fichier security.yml où bcrypt est déclaré conformément aux recommandations Symfony.

Lorsqu’au travers du formulaire de connexion l’utilisateur fournit un identifiant et un mot de passe, le système de sécurité utilise les fournisseurs d’utilisateurs configurés pour retourner des objets utilisateur pour un nom d’utilisateur donné.

Nous avons vu comment sont renseignée les règles de pare feu pour indiquer si une url donnée est soumise à authentification. Nous avons également vu la mise en place d’un système d’authentification par formulaire de connexion et nous savons que le système de sécurité authentifie les utilisateurs, dans notre cas au travers de la table d’utilisateurs. Ok ? peut-être maintenant allons nous enfin nous connecter non ? et ce formulaire de connexion il ne va pas rester comme ça, si ? Patience…

Paramétrer le pare feu pour se souvenir de l’utilisateur

Symfony2 possède une fonctionnalité qui permet à un utilisateur de faire persister ses crédits alloués à la connexion au delà d’une simple session. Lors de la connexion l’utilisateur peut choisir de se reconnecter automatiquement au travers d’une option remember_me sous forme de case à cocher dans le formulaire de connexion. Si l’utilisateur coche la case se souvenir de moi, et qu’il ferme sa session, la prochaîne fois qu’il se rend sur l’application il sera automatiquement connecté grace à un cookie de rappel.

Pour utiliser cette fonctionnalité il faut indiquer dans le pare feu de l’activer au niveau du formulaire et configurer l’option.

...
            form_login:
                provider: fos_userbundle
                csrf_provider: security.csrf.token_manager # Use form.csrf_provider instead for Symfony <2.4
                remember_me: true
                default_target_path: /
            logout:       true
            anonymous:    true
            remember_me:
                key:      %secret%
                lifetime: 604800 # 1 week in seconds
                path:     /
                domain:   ~ # Defaults to the current domain from $_SERVER
#                secure:   true
                httponly: true
...

Consultez la documentation Symfony2 pour paramétrer l’option.

Dans cet exemple on se souviendra de l’utilisateur pour une durée d’une semaine, le cookie sera associé à tout le site, le domaine est récupéré depuis les variables d’environnement de PHP, et on demande de n’accéder au cookie que par protocole HTTP.

Post Installation

Oui, c’est fait j’ai installé la solution, partons à sa découverte.

Les indicateurs d’état dans l’entité utilisateur

Si vous ouvrez la classe utilisateur \vendor\friendsofsymfony\user-bundle\Model\User.php, vous risquez d’être surpris par le nombre d’indicateurs booléens qui reflètent l’état d’un utilisateur (en standard dans Symfony), c’est un vrai casse tête, et la documentation est introuvable.

Faisons le point : vous ne pouvez pas supprimer un utilisateur qui appartient à une relation, à cause des contraintes d’intégrité référentielles, parfait.
Mais si vous souhaitez désactiver l’utilisateur pour faire en sorte qu’il ne puisse plus se connecter, faut il couper le fil vert ou le fil noir ? Couper les 2 fils en appuyant sur le bouton vert ? Il vous reste 19 secondes…

L’entité utilisateur dispose des indicateurs enabled, locked, expired, credentialsExpired.

Ils sont vérifiés dans cet ordre par le processus d’authentification du composant de sécurité Symfony.

  • Enabled : L’indicateur prend la valeur vrai lorsque l’utilisateur a été vérifié, c’est à dire quand il est propriétaire de son e-mail car il a reçu l’email de confirmation de création de profil et suivi la procédure de vérification.
  • Locked : Cet indicateur permet de vérouiller le compte, l’utilisateur ne peut plus se connecter ou réinitialiser son mot de passe, il ne peut plus manipuler son compte
  • Expired : Un utilisateur est une denrée perrissable, enfin son compte. Un compte expiré permet d’inactiver l’utilisateur : il ne peut plus se connecter. Cela permet de conserver le compte dormant pour archive, et si besoin d’ouvrir à nouveau le compte et forcer l’utilisateur à se valider à nouveau
  • CredentialsExpired : Cet indicateur permet de vérifier après connexion si le certificat est expiré, si c’est le cas l’application devrait forcer l’utilisateur à modifier son mot de passe

Muni de ces informations, vous pouvez désactiver l’utilisateur comme il vous plaira en jouant sur un ou plusieurs indicateurs. Ma préférence va au blocage du compte par verrou qui permet si besoin de réactiver un compte archivé pour consultation tout en évitant de manipuler une date d’expiration

Créer un premier utilisateur

Pour tester le formulaire de connexion, créons un utilisateur. Heureusement FOSUserBundle possède un jeu de commandes interactives.

Créé un utilisateur avec le rôle de super administrateur

console fos:user:create admin --super-admin

Créé un utilisateur avec le seul rôle d’utilisateur en précisant son e-mail et son mot de passe en ligne de commande

console fos:user:create someone someone@somewhere.com somepass

Si tout va bien vous êtes redirigé vers la page d’accueil et un coup d’œil au profileur Symfony vous informe enfin sous quel utilisateur vous êtes connecté !

Si vous retournez sur la page de connexion vous pouvez tenter une déconnexion et une nouvelle connexion avec l’option se souvenir de moi.

Personnaliser la page de connexion

FosUserBundle fournit par défaut des gabarits de mise en pages pour un certain nombre de situations, il est nécessaire de personnaliser ces gabarits pour chaque situation que vous allez mettre en place : voyons comment surcharger les deux gabarits d’agencement et de connexion propres à la page de connexion.

Comme (trop) souvent il existe plusieurs façons de faire, nous nous contenterons de la méthode qui consiste à définir un gabarit du même nom dans le répertoire de ressources de notre bundle applicatif.

Si vous suivez les bonnes pratiques Symfony2, les répertoire app/Resources/ est l’endroit où vous stockez les gabarits et fichiers de traduction pour l’application. Vous pouvez créer un sous répertoire et son arborescence à cet endroit pour surcharger les ressources par défaut de FOSUserBundle.

Créez un répertoire app/Resources/FOSUserBundle/views/ dans lequel vous copierez le fichier vendor\friendsofsymfony\user-bundle\Resources\views\layout.html.twig

Vous pouvez alors personnaliser le fichier que vous venez de copier-coller, dans l’immédiat une balise de titre sera suffisante à la compréhension !

Créez un répertoire app/Resources/FOSUserBundle/views/Security dans lequel vous copierez le fichier vendor\friendsofsymfony\user-bundle\Resources\views\Security\login.html.twig

Vous pouvez alors personnaliser le bloc où s’affiche le formulaire de connexion…

Regardez ce dernier gabarit, FOSUserBundle prend soin d’avoir un rendu international, il utilise des clefs pour le textes à traduire selon la variable de lieu (locale) configurée sous Symfony2. Si vous n’utilisez pas la traduction vous pouvez changer les étiquettes des champs de formulaire directement dans le gabarit, pour ma part j’ai recopié les fichiers FOSUserBundle.fr.yml et validators.fr.yml sous le répertoire app/Resources/FOSUserBundle/translations.

(Note : vider le cache n’a pas suffit à prendre en compte les modifications des fichiers de traduction, cela a nécessité de basculer la variable locale dans me fichier config.yml, puis de la restaurer à sa valeur précédente)

Nous avons vu comment personnaliser le formulaire de connexion, à ce point FOSUserBundle répond sans autres modifications que cosmétiques au besoin de connexion, voyons maintenant comment créer une interface pour l’administration des profils.

Administrer les groupes et les utilisateurs

Je ne suis pas partisan des solutions plus ou moins toutes prêtes d’administration pour plusieurs raisons, allant de la maintenance dans le temps, à la mise en oeuvre en passant par les capacités de personnalisation. Ce n’est qu’un ressentit mais j’ai l’impression à la fois de perdre du temps -qui plus est, du temps de configuration et de résolution de problèmes en tous genres que je considère désagréable- et surtout le contrôle de mon application car les mécanismes de fonctionnement sont souvent opaques.

Ce que je veux dire simplement c’est que l’on se passera d’un SonataAdminBundle pour se contenter des capacités de génération en ligne de commande de Doctrine. C’est un choix, je tenterai prochainement de donner des crédits à l’approche SonataAdminBundle car d’autres l’on fait avec plus ou moins de succès et surtout devant la popularité de la solution, on peut retrouver cette approche dans le cadre d’une maintenance applicative.

Personnaliser l’entité utilisateur à notre besoin

En l’état, FOSUserBundle permet d’ajouter un groupe, de lister les groupes, il permet à un utilisateur de s’enregistrer, et d’autres choses qui sont malheureusement limitées par rapport à une interface d’administration réelle.

Justement, prenons l’exemple d’un besoin client qui implique de modifier la table d’utilisateurs, de ressortir les informations et d’intervenir après connexion : le client souhaite comptabiliser le nombre de connexions de l’utilisateur et enregistrer sa date de première et dernière connexion.

Mettre à jour la classe utilisateur

Ajoutons ces informations dans l’entité utilisateur, en fait il existe déjà une propriété nommée lastLogin dans la classe du bundle, ajoutons les autres propriétés à notre classe User.php :

    /**
     * @ORM\Column(type="integer", length=6, options={"default":0})
     */
    protected $loginCount = 0;
    
    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $firstLogin;   

Insérons les méthodes pour décompter les connexions et enregistrer les informations d’horodatage à la connexion.

console doctrine:generate:entities AppBundle:User

Le décompte de connexions est initialisé par défaut au niveau de la base de données comme configuré dans les options d’annotations Doctrine, par contre il faut ajouter cette initialisation également au niveau de la déclaration de la propriété pour pouvoir créer les utilisateurs depuis le code sans renseigner la propriété.

Mettons à jour la base de données

console doctrine:cache:clear-metadata 
console doctrine:schema:update --force
Alimenter la base avec des utilisateurs factices

Nous pouvons tester la manipulation de notre classe d’utilisateur au travers de l’alimentation de la base en utilisateurs factices.

FOSUserBundle utilise un gestionnaire d’utilisateurs auquel on accède au travers d’un service. Toutes les opérations sur les instances d’utilisateurs devraient être exécutées par ce gestionnaire afin d’assurer l’indépendance au stockage.

L’utilisation des capacités de Doctrine à générer des données pour alimenter une base de test requiert une extension.

composer require "doctrine/doctrine-fixtures-bundle"

Modifiez le fichier AppKernel.php pour inclure le bundle en environnement de développement.

$bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();

Créez un fichier /AppBundle/DataFixtures/ORM/UserFixtures.php

<?php namespace AppBundle\DataFixtures\ORM; use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\OrderedFixtureInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; //use AppBundle\Entity\User; //use AppBundle\Entity\Group; class UserFixtures extends AbstractFixture implements OrderedFixtureInterface, ContainerAwareInterface { /** * @var ContainerInterface */ private $container; /** * {@inheritDoc} */ public function setContainer(ContainerInterface $container = null) { $this->container = $container;
    }
    
    public function getOrder() {
        return 0;
    }

    public function load(ObjectManager $manager) {

        $userManager = $this->container->get('fos_user.user_manager');
        
        $user = $userManager->createUser();
        
        $user
            ->setUsername('someguy')
            ->setEmail('john.doe@example.com')
            ->setFirstLogin(\DateTime::createFromFormat('j-M-Y', '15-Feb-2009'))
            ->setEnabled(true);
        
        $user->setPlainPassword('somepass');

        // Equivalent à :
        
//        $encoder = $this->container
//                ->get('security.encoder_factory')
//                ->getEncoder($user)
//            ;
//        $user->setPassword($encoder->encodePassword('somepass', $user->getSalt()));

        
        $userManager->updateUser($user);
    }
    
}

Vous pouvez lancer la génération des données en mode ajout

console doctrine:fixtures:load --append

Plusieurs choses sont importantes ici :

  • On récupère le gestionnaire d’utilisateur depuis le conteneur qui est passé à la classe automatiquement grace à l’implémentation de l’interface ContainerAwareInterface
  • Nous pouvons encoder le mot de passe avec l’encodeur qui est définit dans le composant de sécurité, il est également récupéré depuis le conteneur, ou bien reposer sur les écouteurs d’événements qui mettent à jour le mot de passe après enregistrement
  • La mise à jour de l’utilisateur effectue les opération de flush Doctrine automatiquement, ce comportement est paramétrable par passage de booléen
  • Par défaut un écouteur d’événement met à jour les champs canoniques (email et username) après enregistrement, ainsi que le traitement de cryptage du mot de passe fourni en clair (plainPassword), ce comportement peut être désactivé
Evénements et écoutes de connexion

Les actions de comptabilisation et d’enregistrement des dates de connexion s’effectuent après une authentification réussie, or le système d’authentification redirige l’utilisateur automatiquement après connexion, sans nous laisser la main pour des traitements de mise à jour !

Sous Symfony ce traitement peut se faire en déclarant un écouteur sur un événement connexion.

Ouvrez le fichiers AppBundle\Resources\config\services.yml

Un écouteur d’événement est un service. Le premier fichier de configuration Symfony permet d’enregistrer notre service qui prendra en charge les traitements après connexion dans le conteneur de services.

services: 
    login_listener:
        class: 'AppBundle\Listener\LoginListener'
        arguments: ['@fos_user.user_manager']
        tags:
            - { name: 'kernel.event_listener', event: 'security.interactive_login' }
            - { name: 'kernel.listener', event: 'fos_user.security.implicit_login' }

Nous déclarons la classe LoginListener qui correspond à l’écouteur.

A l’aide de l’entrée arguments on indique à Symfony d’injecter le service de gestion des utilisateurs du bundle dont notre propre service dépend.

On peut obtenir des informations sur les services de l’application à l’aide d’une commande :

console container:debug

et le détail !

console debug:container fos_user.user_manager

La dernière entrée tags indique de quelle façon sera utilisé le service. Ici il prend la valeur kernel.event_listener qui indique d’écouter les événements propagés par le framework et déclenche la méthode correspondante de la classe de notre service lorsqu’un événement security.interactive_login se produit, c’est à dire lors d’une connexion réussie.

Ce n’est pas tout, FOSUserBundle ajoute une couche supplémentaire qui autorise les connexions implicites, il propage ses propres événements dont ce mode de connexion.

Pour ne pas vieillir prématurément à la recherche de solutions à des problèmes qui devraient être documentés et ne le sont pas on peut s’inspirer des propres écouteurs du bundle, ouvrez le fichier /EventListener/LastLoginListener.php, la propriété qui enregistre la date de dernière connexion est déjà traitée par un système d’écouteur !

Il reste à écrire les traitements dans notre propre écouteur. Créez le fichier AppBundle\Listener\LoginListener.php

ATTENTION, j’ai eu la mauvaise idée d’activer un cache REDIS au niveau des requêtes Doctrine, avec un cache le traitement POST connexion ne fonctionne pas !?
<?php namespace AppBundle\Listener; use FOS\UserBundle\FOSUserEvents; use FOS\UserBundle\Event\UserEvent; use FOS\UserBundle\Model\UserManagerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; use Symfony\Component\Security\Http\SecurityEvents; class LoginListener implements EventSubscriberInterface { protected $userManager; public function __construct(UserManagerInterface $userManager) { $this->userManager = $userManager;
    }
    
    public static function getSubscribedEvents()
    {
        return array(
            FOSUserEvents::SECURITY_IMPLICIT_LOGIN => 'onImplicitLogin',
            SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
        );
    }

    protected function updateUser($user) {
        
        if (!$user->getLoginCount())
            $user->setFirstLogin(new \DateTime());
        
        $user->setLoginCount((int) $user->getLoginCount() + 1);
        
        $this->userManager->updateUser($user);
    }
    
    public function onImplicitLogin(UserEvent $event)
    {
        $this->updateUser($event->getUser());
    }
    
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) {
        $user = $event->getAuthenticationToken()->getUser();
        //if ($user instanceof UserInterface)
            $this->updateUser($user);
    }
}

Je peux enfin fermer cette parenthèse et passer à l’interface d’administration.

Générer un CRUD à l’aide de la console

Ce que l’on souhaite c’est administrer les utilisateurs et les groupes au tra ers de plusieurs écrans : listes, formulaires d’ajout et modification.

La commande doctrine:generate:crud génère les opérations CRUD sur la base d’une entité Doctrine, elle repose sur l’extension SensioGeneratorBundle et les gabarits sont inclus par défaut (sous \vendor\sensio\generator-bundle\Sensio\Bundle\GeneratorBundle\Resources\skeleton\crud). L’aide en ligne de la commande nous informe que l’on peut étendre ces gabarits pour les personnaliser en les copiant sous un répertoire de notre bundle, ce qui est certainement ce qu’il faudrait faire si personne n’avait fait encore mieux avant !

Pour notre application nous souhaitons nous appuyer sur des valeurs sûres et utiliser Twitter Bootstrap 3 et Font Awesome pour nous aider dans la mise en page css, cela tombe bien car le générateur par défaut a été étendu par un bundle qui lui apporte quelques fonctionnalités et surtout intègre par défaut Twitter Bootstrap 3 et Font Awesome, alors utilisons le, il se nomme
PUGXGeneratorBundle. Nous installerons conjointement un bundle qui nous permettra d’ajouter les fonctionnalités de pagination au CRUD, il s’agit de KnpPaginatorBundle, un bundle qui permet d’ajouter les fonctionnalités de filtre nommé LexikFormFilterBundle, et également un bundle qui permet de générer des données factices aléatoires : BazingaFakerBundle.

Après génération du CRUD, nous pourrons appliquer un filtre sur la liste des utilisateurs qui, après modification du code, pourra inclure une sélection de groupes.

symfony2-filtre

Commençons par télécharger les sources…

composer require pugx/generator-bundle:2.4.* --dev
composer require knplabs/knp-paginator-bundle
composer require "lexik/form-filter-bundle"
composer require "willdurand/faker-bundle"

Editez le fichier AppKernel.php pour enregistrer les bundles au sein de l’application.

En environnement de test uniquement pour les générateurs de CRUD et de données factices.

$bundles[] = new PUGX\GeneratorBundle\PUGXGeneratorBundle();
$bundles[] = new Bazinga\Bundle\FakerBundle\BazingaFakerBundle();

Et dans tous les cas pour la pagination et le filtre.

new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(),
new Lexik\Bundle\FormFilterBundle\LexikFormFilterBundle(),

Les bundles installés, il faut désormais les configurer ! Et toujours aucune ligne de code tapée, patience…

Configurons en premier le générateur de données factices, il faut d’abord autoriser la configuration du bundle dans app/config/config_dev.yml

bazinga_faker: ~

Ensuite comme on utilisera ce bundle en dev avec l’orm Doctrine, avec des jeux de données en français on va paramétrer l’orm et la variable de localisation, puis l’entité utilisateur à hauteur de 20 utilisateurs, ce qui nous permettra de tester comment se comporte la pagination.

bazinga_faker:
    orm: doctrine
    locale: fr_FR
    entities:
        AppBundle\Entity\User:
            number: 20

Nous pouvons lancer la génération des entités paramétrées et vérifier le résultat en base de données.

console faker:populate

La librairie PHP Faker permet de personnaliser les formats de champs, pour aller au plus simple on s’est contenté ici du comportement par défaut sans paramétrer les champs, cependant pour éviter des déconvenues nous allons altérer la table une fois les données crées pour renseigner les données de type tableau.

console doctrine:query:sql "UPDATE fos_user SET roles = 'a:0:{}' where roles = 'N;'"

Nous disposons alors de données exploitables !

faker-users

Configurons maintenant le bundle de filtre. Il faut ajouter les blocs de gabarits utilisés dans les formulaires de filtre aux ressources de formulaire du moteur Twig. Editez le fichier app/config/config.yml et ajoutez ces lignes sous la configuration twig :

twig:
    form:
        resources:
            - LexikFormFilterBundle:Form:form_div_layout.html.twig

Nous utilisons PostgreSQL aussi nous pouvons préciser dans la configuration si les recherches sont sensibles à la casse de caractères, le dernier paramètre n’est pas clair pour moi à l’heure où j’écris ces ligne, je le met dans la configuration pour pouvoir tester le fonctionnement lorsque l’on change ce paramètre pour ses valeurs prédéfinies.

lexik_form_filter:
    force_case_insensitivity: false
    where_method: ~  # null | and | or

Le générateur de CRUD apporte une nouvelle commande similaire à la commande doctrine:generate:crud

console pugx:generate:crud --help

L’usage nous informe que la commande ne génère par défaut que les actions de liste et d’aperçu, les actions d’ajouts, édition et suppressions sont conditionnées par l’option –with-write

Nous utiliseront également la pagination et le tri par colonnes sur l’action de liste des utilisateurs (ce ne sera pas nécessaire sur les groupes, leur nombre étant très limité), ces fonctions sont conditionnées par les options –use-paginator et –with-sort.

Paramétrons la pagination ! Editez le fichier config.yml et ajoutez une section. Ici je n’ai modifié par rapport à la documentation que le gabarit de pagination pour utiliser le gabarit prêt à l’emploi du bundle pour Twitter Bootstrap 3.

knp_paginator:
    page_range: 5                      # default page range used in pagination control
    default_options:
        page_name: page                # page query parameter name
        sort_field_name: sort          # sort field query parameter name
        sort_direction_name: direction # sort direction query parameter name
        distinct: true                 # ensure distinct results, useful when ORM queries are using GROUP BY statements
    template:
        pagination: KnpPaginatorBundle:Pagination:twitter_bootstrap_v3_pagination.html.twig 
        sortable: KnpPaginatorBundle:Pagination:sortable_link.html.twig # sort link template

Le gabarit nécessite une traduction, pour cela éditez le fichier du domaine messages, par ex. /app/Resources/translations/messages.fr.yml

Next:       Suivant
Previous:   Précédent

Si ce n’est pas déjà fait, assurez vous d’incorporer les feuilles de styles et fichiers JavaScripts pour la mise en page. Ce fichier app\Resources\views\base.html.twig fait appel aux ressources depuis un CDN, cela sera suffisant pour commencer !

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>{% block title %}Welcome!{% endblock %}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        {% block stylesheets %}
            <link href="//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">
            <link href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css" rel="stylesheet">
        {% endblock %}
        <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" />
    </head>
    <body>

<nav class="navbar navbar-fixed-top">
            <!-- put your nav bar here -->
        </nav>


<div class="container">
            {% block body '' %}
        </div>

        {% block javascripts %}
            <script src="//code.jquery.com/jquery-2.1.1.js"></script>
            <script src="//netdna.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
        {% endblock %}
    </body>
</html>

Les gabarits générés supportent l’internationalisation lorsqu’elle est activée, pourquoi pas, créons un fichier app/Resources/translations/admin.fr.yml

"%entity% creation":             "Création - %entity%"
"%entity% edit":                 "Modification - %entity%"
"%entity% list":                 "Liste - %entity%"
Actions:                         Actions
Back to the list:                Retour à la liste
Confirm delete:                  Confirmer la suppression
Create:                          Créer
Create a new entry:              Nouveau
Delete:                          Supprimer
"Do you want to proceed?":       "Voulez-vous poursuivre?"
Edit:                            Modifier
edit:                            modifier
Filter:                          Filtrer
No:                              Non
Reset filters:                   Annuler les filtres
show:                            voir
"Show/hide filters":             "Afficher/masquer les filtres"
this procedure is irreversible:  cette procédure est irréversible
Yes:                             Oui
You are about to delete an item: Vous êtes sur le point de supprimer un élément

Nous utilisons Bootstrap 3 et la distribution de Symfony 2 utilise fort heureusement des thèmes pour le rendu des formulaires sous ce framework css, ce qui nous facilite la tâche et nous économise beaucoup de travail.

Ouvrez le répertoire \vendor\symfony\symfony\src\Symfony\Bridge\Twig\Resources\views\Form, vous devriez trouver deux thèmes Twitter Bootstrap 3, l’un pour la mise en page horizontale, l’autre pour la mise en page par défaut. (respectivement bootstrap_3_horizontal_layout.html.twig et bootstrap_3_layout.html.twig)

(Documentation pour personnaliser les formulaires Symfony 2)

Je suis sûr que je personnaliserai rapidement quelque peu ces gabarits, je préfère d’emblée dupliquer ces fichiers sous le bundle applicatif. Copiez le répertoire \vendor\symfony\symfony\src\Symfony\Bridge\Twig\Resources\views\Form vers \src\AppBundle\Resources\views\Form

Tout est prêt, il reste à générer les actions pour nos entités !

En premier commençons par les groupes, un groupe n’a que très peu de propriétés : un identifiant, un nom et un tableau qui contient des rôles. De plus je n’imagine pas de cas d’utilisations avec des dizaines de groupes, ce qui nous permet d’ignorer les fonctionnalités de tri et de filtre et rend ce cas le plus simple pour appréhender le fonctionnement du générateur.

console pugx:generate:crud \
--entity=AppBundle:Group \
--layout=::base.html.twig \
--theme=AppBundle:Form:bootstrap_3_layout.html.twig \
--with-write \
--route-prefix="admin/groups"

La commande est assez parlante en soi, on souhaite générer les actions CRUD de l’entité qui décrit un groupe, pour la génération on précise le gabarit de mise en page et le thème à appliquer aux formulaire, on souhaite générer les actions d’édition et on déclare que nos routes seront préfixées par /admin/groups pour toutes les actions générées pour cette entité.

Le générateur a créé un contrôleur nommé GroupController, des templates (edit, index, new et show) sous /Resources/views/group, un type de formulaire sous Form/Type et des tests /Tests/GroupControllerTest.php

Remarquez que j’ai utilisé dans l’immédiat le gabarit principal de l’application pour la mise en page, on aurait bien sûr pu utiliser une mise en page spécifique pour l’administration ou le bundle (ex. –layout=AppBundle::layout.html.twig ou –layout=::admin.html.twig)

Vous pouvez vous rendre sur l’url par défaut de l’administration pour lister tous les groupes : http://192.168.30.30/admin/groups. La première impression, c’est que l’interface est très propre sans avoir rien fait à ce niveau ! Si vous avez créé un groupe (FOSUserBundle a défini une route pour cela, vous vous rappelez ?) vous pouvez voir votre groupe listé avec pour seul champ l’identifiant, où sont donc passés le libellé et les rôles ? Eh bien si l’on regarde de près la class Form/Type/GroupType, on constate que la méthode chargée de construire un formulaire est bien vide, l’héritage de la classe FOS\UserBundle\Model\Group n’a pas été pris en compte, c’est assez décevant mais pas insurmontable.

Bien, amis bricoleurs, sortez vos boîtes à outils.

Faites une sauvegarde de vos entités, il suffit de les déplacer temporairement quelques part.

C’est fait ? Bien ! Nous allons les régénérer depuis la base de données, ce qui aura pour effet de fusionner toutes les propriétés qui ont servi à construire les tables et relations.

Récupérez les informations de mise en correspondance des entités aux tables :

console doctrine:mapping:import  "AppBundle" xml

Ceci a pour effet de créer deux fichiers xml qui structurent l’information de nos entités, renommez ces fichiers.

AppBundle/Resources/config/doctrine/FosGroup.orm.xml devient Group.orm.xml
AppBundle/Resources/config/doctrine/FosUser.orm.xml devient User.orm.xml

Ouvrez ces fichiers et renommez les occurrences des entités FosGroup et FosUser pour faire disparaître le préfixe Fos. Vous pouvez alors ré-générer les entités :

console doctrine:generate:entities AppBundle

La suite vous la connaissez, il suffit de reprendre là où l’on a abandonné et générer le CRUD pour chaque classe, je vous donne la commande de génération de l’entité utilisateur, on rajoute le filtre et la pagination sur cette commande.

console pugx:generate:crud \
--entity=AppBundle:User \
--layout=::base.html.twig \
--theme=AppBundle:Form:bootstrap_3_layout.html.twig \
--with-write --with-filter --with-sort --use-paginator \
--route-prefix="admin/users"

Une fois que c’est fait, supprimez les fichiers de structure xml et remplacez les entités User et Group par les sauvegardes que vous aviez réalisé. Rendez vous sur l’url d’administration, ce n’est pas fini mais c’est déjà beaucoup plus complet.

En l’état nous avons encore un certain nombre de problèmes à régler, et beaucoup de personnalisations à effectuer.

Le temps passe et tout documenter est particulièrement long, aussi je ne m’attarderai pas sur chaque problème et sur chaque modification, en l’état l’application soulève des exceptions sur de nombreux cas d’utilisation, et j’ai fait des choix personnels qui ne vous conviendront sans doute pas dans le cadre de vos projets. Je vais quand même fournir l’essentiel, et expliquer quelques points pour vous faire gagner du temps !

Premièrement, les gabarits pour chaque écran. Ici il n’y a pas de difficulté, vous êtes en mesure de personnaliser les gabarits vous même en une poignée d’heures. Je n’ai fait que traduire les propriétés des champs en français, supprimer les éléments que je n’utilise pas, ajouter le nom des groupes dans la liste des utilisateurs et ajouter l’état de la connexion utilisateur dans le gabarit principal.

Pour ajouter les groupes à la liste des utilisateurs pensez à désactiver le tri sur la colonne au niveau de l’entête, c’est inutile et soulève une exception.

AppBundle\Resources\views\user\index.html.twig

...

<th scope="col">{{ thead('user', 'username', "Nom d'utilisateur") }}</th>


<th scope="col">{{ thead('user', 'email', 'E-mail') }}</th>


<th scope="col">{{ thead('user', 'locked', 'Verrouillé') }}</th>


<th scope="col">Groupes</th>


<th scope="col">{{ thead('user', 'id', 'Identifiant') }}</th>


<th scope="col">{{ 'Actions'|trans({}, 'admin') }}</th>

...

<td><a href="{{ path('admin_users_show', {id: user.id}) }}">{{ user.username }}</a></td>


<td>{{ user.email }}</td>


<td>{% if user.locked %}<i class="fa fa-check-square-o"></i>{% else %}<i class="fa fa-square-o"></i>{% endif %}</td>


<td>{{ user.groups|join(', ') }}</td>


<td>{{ user.id }}</td>

Remarquez que je n’affiche pas les rôles, c’est délibéré car je souhaite que les utilisateurs finaux lambda n’aient pas conscience des permissions au travers de rôles mais uniquement au travers de leur appartenance à un groupe. Le seul endroit où j’affiche les rôles c’est lors de l’édition d’un groupe.

Pour indiquer à l’utilisateur s’il est connecté et sous quel profil j’ai modifié le gabarit principal de l’application.

        {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
            Connecté en tant que {{ app.user.username }}
            -
            <a href="{{ path('fos_user_security_logout') }}">Déconnexion</a>
        {% else %}
            <a href="{{ path('fos_user_security_login') }}">Connexion</a>
        {% endif %}

Pour finir avec les gabarits, sachez que j’ai laissé pour le moment la possibilité de supprimer les utilisateurs et les groupes. Je n’ai pas d’autres relations utilisateur mais il sera préférable de désactiver l’utilisateur plutôt que de le supprimer. Pour supprimer un groupe il faut qu’il n’y ai plus de relations utilisateur/ groupe définie, j’ai donc rajouté un traitement pour supprimer les relations au niveau de l’action de suppression avant de supprimer le groupe. (je dois améliorer ce point et déplacer le code dans un dépôt)

            // @TODO refactoriser dans le modèle
            $em = $this->getDoctrine()->getManager();
            $users = $group->getUsers();
            foreach ($users as $user)
                $user->getGroups()->removeElement($group);
            $em->flush();

            $this->get('fos_user.group_manager')->deleteGroup($group);

Pourquoi je rentre dans ces détails ? Pour parler de la méthode getUsers() car par défaut la relation qui permet de récupérer les utilisateurs depuis un groupe n’est pas dans la documentation du bundle FOSUserBundle. Voici l’entité groupe finale (remarquez l’initialisation du nom à vide dans le constructeur)

<?php // src/AppBundle/Entity/Group.php namespace AppBundle\Entity; use FOS\UserBundle\Model\Group as BaseGroup; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity * @ORM\Table(name="fos_group") */ class Group extends BaseGroup { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @ORM\ManyToMany(targetEntity="AppBundle\Entity\User", mappedBy="groups") * */ protected $users; public function __construct($name = '', $roles = array()) { $this->name = $name;
        $this->roles = $roles;
    }

    public function __toString() {
        return $this->getName();
    }

    function getUsers() {
        return $this->users;
    }

}

Bien, et cette relation se déclare également dans l’entité utilisateur ! (inversedBy) Voici la classe utilisateur avec son lot de changements.

<?php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 * @UniqueEntity(fields="usernameCanonical", errorPath="username", message="fos_user.username.already_used", groups={"Default", "Registration", "Profile"})
 * @UniqueEntity(fields="emailCanonical", errorPath="email", message="fos_user.email.already_used", groups={"Default", "Registration", "Profile"})
 */
class User extends BaseUser {

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Group", inversedBy="users")
     * @ORM\JoinTable(name="fos_user_user_group",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
     * )
     */
    protected $groups;

    /**
     * @ORM\Column(type="integer", length=6, options={"default":0})
     */
    protected $loginCount = 0;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $firstLogin;

    public function __construct() {
        parent::__construct();
        $this->groups = new ArrayCollection();
    }

    /**
     * Set loginCount
     *
     * @param integer $loginCount
     *
     * @return User
     */
    public function setLoginCount($loginCount) {
        $this->loginCount = $loginCount;
        return $this;
    }

    /**
     * Get loginCount
     *
     * @return integer
     */
    public function getLoginCount() {
        return $this->loginCount;
    }

    /**
     * Set firstLogin
     *
     * @param \DateTime $firstLogin
     *
     * @return User
     */
    public function setFirstLogin($firstLogin) {
        $this->firstLogin = $firstLogin;
        return $this;
    }

    /**
     * Get firstLogin
     *
     * @return \DateTime
     */
    public function getFirstLogin() {
        return $this->firstLogin;
    }

    function getEnabled() {
        return $this->enabled;
    }

    function getLocked() {
        return $this->locked;
    }

    function getExpired() {
        return $this->expired;
    }

    function getExpiresAt() {
        return $this->expiresAt;
    }

    function getCredentialsExpired() {
        return $this->credentialsExpired;
    }

    function getCredentialsExpireAt() {
        return $this->credentialsExpireAt;
    }

    function setSalt($salt) {
        $this->salt = $salt;
    }

    public function setPassword($password) {
        if ($password !== null)
            $this->password = $password;
        return $this;
    }

    function setGroups(Collection $groups = null) {
        if ($groups !== null)
            $this->groups = $groups;
    }

    public function setRoles(array $roles = array()) {
        $this->roles = array();
        foreach ($roles as $role)
            $this->addRole($role);
        return $this;
    }

    public function hasGroup($name = '') {
        return in_array($name, $this->getGroupNames());
    }

}

Au nombre des changements, la création de getters, la modifications de la méthode setGroups() pour pouvoir recevoir des paramètres null, l’initialisation des groupes dans le constructeur avec un ArrayCollection.

Parmi les changements, il a fallut prendre en compte le caractère obligatoire de saisie du mot de passe en création, qui devient facultatif en modification (et du caractère facultatif du vérrouillage en création). Ce dernier point est possible grâce au passage d’une option lors de la construction du formulaire :

// AppBundle\Controller\UserController.php
    /**
     * Displays a form to edit an existing User entity.
     *
     * @Route("/{id}/edit", name="admin_users_edit", requirements={"id"="\d+"})
     * @Method("GET")
     * @Template()
     */
    public function editAction(User $user)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

Et dans le type :

<?php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolverInterface; class UserType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('username', null, array('label' => "Nom d'utilisateur"))
            ->add('email', null, array('required' => false, 'label' => 'E-mail'))
            ->add('plainPassword', 'repeated', array(
                'type' => 'password',
                'invalid_message' => 'Les mots de passe doivent être identiques.',
                'required' => $options['passwordRequired'],
                'first_options'  => array('label' => 'Mot de passe'),
                'second_options' => array('label' => 'Répétez le mot de passe'),
            ))
            ->add('groups', 'entity', array(
                'label' => 'Groupes',
                'multiple' => true,
                'expanded' => true,
                'required' => false,
                'class' => 'AppBundle\Entity\Group'))
        ;
        if ($options['lockedRequired']) {
            $builder->add('locked', null, array('required' => false, 
                'label' => 'Vérouiller le compte'));
        }
        
    }

    /**
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User',
            'passwordRequired' => true,
            'lockedRequired' => false,
        ));
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'user';
    }
}

Remarquez que l’on ne manipule que le mot de passe saisi plein texte, et non pas le mot de passe crypté.

The Symfony validator is enabled by default, but you must explicitly enable annotations if you’re using the annotation method to specify your constraints:

FosUserBundle utilise une configuration xml pour la validation des formulaires, l’unicité des noms d’utilisateur et d’email sont définis dans un groupe nommé Registration localisé dans le fichier vendor\friendsofsymfony\user-bundle\Resources\config\storage-validation\orm.xml

Par défaut le formulaire généré par le CRUD n’applique pas la validation sur ce groupe, il ne prend que le groupe par défaut, il faut donc soit rajouter manuellement le groupe soit gérer ces contraintes dans le groupe par défaut. Depuis la version 2.5 de Symfony la validation spécifique aux méthodes de stockage est désactivée par défaut mais pour cette dernière solution nous allons utiliser les annotations, en effet nous n’utilisons l’utilisateur que dans le cadre de l’ORM et le format xml manque d’aspect pratique. (Vous pouvez bien entendu surcharger les fichiers xml sous votre propre répertoire de ressources si vous préférez)

Sous Symfony 2.7 la validation par xml et yaml est activée par défaut mais on doit explicitement autoriser les annotations si l’on souhaite utiliser cette méthode pour indiquer nos contraintes. On doit l’activer dans la configuration (app/config/config.yml)

framework:
    validation: { enable_annotations: true }

On peut alors ajouter des annotations à la classe utilisateur pour gérer par défaut l’unicité des deux champs. (L’activation par défaut ne permettra pas que les règles fonctionnent aussi lorsque l’on cherchera à se connecter en LDAP et que l’utilisateur sera ajouté après connexion réussie à la base d’utilisateurs de l’application, ce cas doit être géré differemment)

...
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
...
/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 * @UniqueEntity(fields="usernameCanonical", errorPath="username", message="fos_user.username.already_used", groups={"Default", "Registration", "Profile"})
 * @UniqueEntity(fields="emailCanonical", errorPath="email", message="fos_user.email.already_used", groups={"Default", "Registration", "Profile"})
 */
class User extends BaseUser {
...

Notez également l’enregistrement de l’utilisateur dans le contrôleur… par défaut le générateur utilise l’ORM mais FOSUserBundle va plus loin et emploi un gestionnaire d’utilisateur. Bien sûr on pourrait utiliser l’ORM car l’application n’est pas destinée à autre chose, mais autant faire les choses selon les règles. (On autorise par défaut le compte utilisateur créé, qui n’est pas autorisé par défaut à moins d’avoir confirmé son e-mail)

    /**
     * Creates a new User entity.
     *
     * @Route("/create", name="admin_users_create")
     * @Method("POST")
     * @Template("AppBundle:User:new.html.twig")
     */
    public function createAction(Request $request)
    {
        $user = new User();
        $form = $this->createForm(new UserType(), $user);
        if ($form->handleRequest($request)->isValid()) {
            $user->setEnabled(true);
            $userManager = $this->get('fos_user.user_manager');
            $userManager->updateUser($user);

            return $this->redirect($this->generateUrl('admin_users_show', array('id' => $user->getId())));
        }

        return array(
            'user' => $user,
            'form'   => $form->createView(),
        );
    }

En modification cela devient :

    /**
     * Displays a form to edit an existing User entity.
     *
     * @Route("/{id}/edit", name="admin_users_edit", requirements={"id"="\d+"})
     * @Method("GET")
     * @Template()
     */
    public function editAction(User $user)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

    /**
     * Edits an existing User entity.
     *
     * @Route("/{id}/update", name="admin_users_update", requirements={"id"="\d+"})
     * @Method("PUT")
     * @Template("AppBundle:User:edit.html.twig")
     */
    public function updateAction(User $user, Request $request)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        if ($editForm->handleRequest($request)->isValid()) {
            $userManager = $this->get('fos_user.user_manager');
            $userManager->updateUser($user);
            
            return $this->redirect($this->generateUrl('admin_users_edit', array('id' => $user->getId())));
        }
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

Et maintenant un sujet épineux encore mal documenté, et seulement en anglais : comment je configure une relation many-to-many dans un filtre ? Oui, c’est beau les bundles qui fonctionnent dans 90% des cas simples mais n’avez vous pas l’impression d’être 100% du temps dans les 10% de cas plus complexes ?

LexikFormFilterBundle peut fonctionner avec une relation many-to-many utilisateur / groupe, le filtre est créé pour la liste des utilisateurs, j’ai restreint au minimum les propriétés. Les recherches sont cumulatives par défaut, c’est à dire que si l’on renseigne un nom d’utilisateur et un email, la recherche se traduira par une condition de ET logique. Les recherches supportent l’opérateur SQL LIKE, de ce fait on peut effectuer une recherche partielle à l’aide du caractère ‘%’ (ex. ‘sam%’ retournera tous les noms d’utilisateurs qui commencent par les caractères ‘sam’)

L’option magique s’appelle ‘apply_filter’, n’oubliez pas la directive use! Et le voici en action :

<?php // \AppBundle\Form\Type\UserFilterType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolverInterface; use Lexik\Bundle\FormFilterBundle\Filter\Query\QueryInterface; class UserFilterType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('username', 'filter_text', array('label' => "Nom d'utilisateur"))
                ->add('email', 'filter_text', array('label' => 'E-mail'))
                ->add('enabled', 'filter_boolean', array('label' => 'Autorisé'))
                ->add('groups', 'filter_entity', array(
                    'label' => 'Groupes',
                    'class' => 'AppBundle\Entity\Group',
                    'expanded' => true,
                    'multiple' => true,
                    'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
                        $query = $filterQuery->getQueryBuilder();
                        $query->leftJoin($field, 'm');
                        // Filter results using orWhere matching ID
                        foreach ($values['value'] as $value) {
                            $query->orWhere($query->expr()->in('m.id', $value->getId()));
                        }
                    },
                ))
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver) {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User',
            'csrf_protection' => false,
            'validation_groups' => array('filter'),
            'method' => 'GET',
        ));
    }

    /**
     * {@inheritdoc}
     */
    public function getName() {
        return 'user_filter';
    }
}

(A ce stade vous voulez certainement me payer une bière pour toutes ces journées économisées, mais je m’égare.)

Récapitulons, nous avons un CRUD fonctionnel pour la gestion des utilisateurs et des groupes, qu’il reste juste à personnaliser au cas par cas.
L’administration permet de sélectionner des rôles dans les groupes, et de relier les utilisateurs aux groupes, les utilisateurs possèdent donc les rôles cumulés des groupes auxquels ils appartiennent, ils possèdent leurs propres rôles définis en base et qui peuvent être promus en ligne de commande (que l’on administre pas dans les écrans) et ils bénéficient également de rôles par héritage tels que définis dans le composant de sécurité.

Par contre en l’état tout profil connecté avec un role ROLE_ADMIN peut tout modifier dans l’interface d’administration, nous allons voir comment gérer les autorisations et appliquer des restrictions sur les pages d’administration mais en premier faisons un peu de ménage.

Supprimer les routes inutilisées

En dehors des routes définies pour la sécurité, nous n’utilisons plus pour nos besoins d’administration FOSUserBundle.

fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"

#fos_user_profile:
#    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
#    prefix: /profile
#
#fos_user_register:
#    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
#    prefix: /register
#
#fos_user_resetting:
#    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
#    prefix: /resetting
#
#fos_user_change_password:
#    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
#    prefix: /profile
#
#fos_user_group:
#    resource: "@FOSUserBundle/Resources/config/routing/group.xml"
#    prefix: /group

Gérer les droits

Nous avons une base d’utilisateurs en mesure de se connecter, et comme désormais on est en mesure de gérer les groupes et les rôles associés, tous ces utilisateurs n’ont pas les mêmes permissions, et ce que vous ferez des autorisations sera à étudier au cas par cas. Voyons les principales conditions d’application.

Afficher un menu selon le contexte

Notre interface d’administration est assez peu pratique sans un menu, et il est facile d’en construire un en dur directement dans le rendu html, mais cela s’avère beaucoup plus pratique de gérer les autorisations lorsque le menu est construit dynamiquement et nous utiliserons à cette fin un bundle dédié à cela, KnpMenuBundle, pour lequel on personnalisera le rendu pour Twitter Bootstrap 3.

Commencez par télécharger les sources

composer require "knplabs/knp-menu-bundle"

Déclarez le bundle au sein de l’application dans le fichier AppKernel.php

...
new Knp\Bundle\MenuBundle\KnpMenuBundle(),
...

Le bundle fournit une méthode sous twig qui effectue le rendu du menu. Nous pouvons passer le gabarit personnalisé pour Twitter Boostrap 3 à cette méthode. Voici un extrait d’un fichier de mise en page pour l’interface d’administration :

//backend.html.twig
...
{% block header %}
    <!-- Header -->

<div id="header">

<div class="color-line">
        </div>


<div id="logo" class="light-version">
            <span>
                Administration
            </span>
        </div>


<nav role="navigation">

<div class="header-link hide-menu"><i class="fa fa-bars"></i></div>


<div class="small-logo">
                <span class="text-primary">Administration</span>
            </div>


<div id="navbar" class="navbar-collapse collapse">
                {{ knp_menu_render('AppBundle:MenuBuilder:buildMainMenu', {'currentClass': 'active', 'template': ':Menu:knp_menu.html.twig'}) }}
                {{ knp_menu_render('AppBundle:MenuBuilder:buildUserMenu', {'currentClass': 'active', 'template': ':Menu:knp_menu.html.twig'}) }}
            </div>

        </nav>

    </div>

{% endblock %}
...

Vous pouvez constater que l’on utilise deux fois la méthode, le premier appel sert à générer le menu principal qui s’affichera de gauche à droite en se décalant vers le centre après le rendu du logo. Ce menu servira à gérer les entités utilisateur et groupe. Le second appel quand à lui permet de générer un menu qui s’affichera à droite, ce menu est destiné à afficher les informations sur l’utilisateur connecté et lui permettre de se déconnecter ou se connecter sous un autre profil.

Vous pouvez créer le fichier qui correspond au gabarit, dans notre exemple il est situé sous app\Resources\views\Menu\knp_menu.html.twig, il reprend le travail précieux d’un internaute – merci à lui !-, à consulter.

{% extends 'knp_menu.html.twig' %}

{% block item %}
{% import "knp_menu.html.twig" as macros %}
{% if item.displayed %}
    {%- set attributes = item.attributes %}
    {%- set is_dropdown = attributes.dropdown|default(false) %}
    {%- set divider_prepend = attributes.divider_prepend|default(false) %}
    {%- set divider_append = attributes.divider_append|default(false) %}

{# unset bootstrap specific attributes #}
    {%- set attributes = attributes|merge({'dropdown': null, 'divider_prepend': null, 'divider_append': null }) %}

    {%- if divider_prepend %}
        {{ block('dividerElement') }}
    {%- endif %}

{# building the class of the item #}
    {%- set classes = item.attribute('class') is not empty ? [item.attribute('class')] : [] %}
    {%- if matcher.isCurrent(item) %}
        {%- set classes = classes|merge([options.currentClass]) %}
    {%- elseif matcher.isAncestor(item, options.depth) %}
        {%- set classes = classes|merge([options.ancestorClass]) %}
    {%- endif %}
    {%- if item.actsLikeFirst %}
        {%- set classes = classes|merge([options.firstClass]) %}
    {%- endif %}
    {%- if item.actsLikeLast %}
        {%- set classes = classes|merge([options.lastClass]) %}
    {%- endif %}

{# building the class of the children #}
    {%- set childrenClasses = item.childrenAttribute('class') is not empty ? [item.childrenAttribute('class')] : [] %}
    {%- set childrenClasses = childrenClasses|merge(['menu_level_' ~ item.level]) %}

{# adding classes for dropdown #}
    {%- if is_dropdown %}
        {%- set classes = classes|merge(['dropdown']) %}
        {%- set childrenClasses = childrenClasses|merge(['dropdown-menu']) %}
    {%- endif %}

{# putting classes together #}
    {%- if classes is not empty %}
        {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
    {%- endif %}
    {%- set listAttributes = item.childrenAttributes|merge({'class': childrenClasses|join(' ') }) %}

{# displaying the item #}
    <li{{ macros.attributes(attributes) }}>
        {%- if is_dropdown %}
            {{ block('dropdownElement') }}
        {%- elseif item.uri is not empty and (not item.current or options.currentAsLink) %}
            {{ block('linkElement') }}
        {%- else %}
            {{ block('spanElement') }}
        {%- endif %}
{# render the list of children#}
        {{ block('list') }}
    </li>


    {%- if divider_append %}
        {{ block('dividerElement') }}
    {%- endif %}
{% endif %}
{% endblock %}

{% block dividerElement %}
{% if item.level == 1 %}

<li class="divider-vertical"></li>

{% else %}

<li class="divider"></li>

{% endif %}
{% endblock %}

{% block linkElement %}
	<a href="{{ item.uri }}"{{ macros.attributes(item.linkAttributes) }}>
		{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
		{{ block('label') }}
	</a>
{% endblock %}

{% block spanElement %}
	<span>{{ macros.attributes(item.labelAttributes) }}>
		{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
		{{ block('label') }}
	</span>
{% endblock %}

{% block dropdownElement %}
    {%- set classes = item.linkAttribute('class') is not empty ? [item.linkAttribute('class')] : [] %}
    {%- set classes = classes|merge(['dropdown-toggle']) %}
    {%- set attributes = item.linkAttributes %}
    {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
    {%- set attributes = attributes|merge({'data-toggle': 'dropdown'}) %}
    <a href="#"{{ macros.attributes(attributes) }}>
    	{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
    	{{ block('label') }} 
    	<b class="caret"></b>
    </a>
{% endblock %}

{%- block label %}
{{ item.label|trans(
    item.getExtra('translation_params', {}),
    item.getExtra('translation_domain', 'messages')
) }}
{%- endblock %}

La construction des menus s’opère dans la classe déclarée dans le gabarit lors de l’appel à la méthode de rendu, ici vous avez à créer un fichier AppBundle\Menu\MenuBuilder.php

<?php namespace AppBundle\Menu; use Knp\Menu\FactoryInterface; use Symfony\Component\DependencyInjection\ContainerAware; class MenuBuilder extends ContainerAware { public function buildMainMenu(FactoryInterface $factory, array $options) { $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders');

        $menu->addChild('entities', array(
                    'label' => 'Gestion'
                ))
                ->setAttribute('dropdown', true)
                ->setAttribute('icon', 'fa fa-list');

        $menu['entities']
                ->addChild('users', array(
                    'route' => 'admin_users',
                    'label' => 'Utilisateurs'));

        $menu['entities']
                ->addChild('groups', array(
                    'route' => 'admin_groups',
                    'label' => 'Groupes'));

        return $menu;
    }

    public function buildUserMenu(FactoryInterface $factory, array $options) {
        $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders navbar-right');

        $context = $this->container->get('security.context');
        if ($context->isGranted('IS_AUTHENTICATED_REMEMBERED')) {

            $menu->addChild('profile', array(
                        'label' => $context->getToken()->getUser()->getUsername()))
                    ->setAttribute('dropdown', true)
                    ->setAttribute('icon', 'fa fa-user');

            $menu['profile']->addChild('Se déconnecter', array('route' => 'fos_user_security_logout'))
                    ->setAttribute('icon', 'fa fa-unlink');
            $menu['profile']->addChild("Se connecter sous un autre profil", array('route' => 'fos_user_security_login'))
                    ->setAttribute('icon', 'fa fa-link');
        }

        return $menu;
    }

}

Le principe est simple, nous avons deux méthodes, une par menu. KnpMenuBundle permet de construire des menus sous forme d’arborescence d’items, un peu à la façon dont on manipulerai un fichier xml par le code. Le rendu html du menu sera une liste html non ordonnée, elle débutera par un élément balisé UL et contiendra des balises LI. Premièrement on créé un élément qui sera à la racine de l’arborescence, auquel on fixe des attributs, dans notre cas la classe pour appliquer un style au rendu :

        $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders');

donnera


<ul class="nav navbar-nav no-borders">

Ce premier élément est créé sous forme d’objet, et on va pouvoir lui ajouter des éléments : d’abord un élément nommé ‘Gestion’ puis des sous éléments à cet élément nommés ‘Groupes’ et ‘Utilisateurs’. Le code est suffisamment explicite, remarquez cependant dans le second cas comment l’on gère l’affichage du nom d’utilisateur.

On utilise simplement le composant de sécurité au travers d’un service, le contexte de sécurité possède une fonction isGranted() qui permet de déterminer si l’utilisateur de l’application possède une ou plusieurs attributions. Dans notre exemple le rôle IS_AUTHENTICATED_REMEMBERED est assigné automatiquement à l’utilisateur qui s’est authentifié au travers d’un cookie, il permet de déterminer si l’utilisateur est connecté ou non.

Dans l’immédiat nous ne définissons pas plus de restrictions au niveau du menu, nous verrons ultérieurement la sécurité. Ce que nous voulons au final c’est que l’utilisateur ne voit que les entrées du menu auxquelles il a accès, et cela implique à ce niveau d’utiliser des constructions if / then / else à maintenir en marge des règles d’accès définies dans le système de routage.

Complétez l’installation du bundle par la déclaration d’un service qui permettra lors de la construction du menu de déterminer quelle entrée du menu correspond à la page courante. Cette entrée active possède une classe css supplémentaire pour démarquer le style dans le rendu du menu.

Le service se paramètre sous le fichier services.yml

services: 
    ...
    menu.voter.request:
        class: AppBundle\Menu\RequestVoter
        arguments: [ @request_stack ]
        tags:
            - { name: knp_menu.voter }

Et il est donc défini dans la classe AppBundle\Menu\RequestVoter.php

<?php namespace AppBundle\Menu; use Knp\Menu\ItemInterface; use Knp\Menu\Matcher\Voter\VoterInterface; use Symfony\Component\HttpFoundation\RequestStack; class RequestVoter implements VoterInterface { private $requestStack; public function __construct(RequestStack $requestStack) { $this->requestStack = $requestStack;
    }

    public function matchItem(ItemInterface $item) {
        $request = $this->requestStack->getCurrentRequest();

        if ($item->getUri() === $request->getRequestUri()) {
            // URL's completely match
            return true;
        } else if ($item->getUri() !== $request->getBaseUrl() . '/' && substr($request->getRequestUri(), 0, strlen($item->getUri())) === $item->getUri()) {
            // URL isn't just "/" and the first part of the URL match
            return true;
        }

        return null;
    }

}

Vérifier les droits au niveau du moteur de rendu Twig

Comment tester si l’utilisateur possède les droits d’accès dans un gabarit ?
Twig offre la possibilité d’accéder au contexte de sécurité, il pourvoit en extension la fonction is_granted() que l’on peut employer à cette fin.

{# L'utilisateur est-il connecté ? #}
{% if app.user and is_granted("IS_AUTHENTICATED_REMEMBERED") %}
    {# L'utilisateur est connecté ! #}
{% else %}
    {# L'utilisateur n'est pas connecté ! #}
{% endif %}

L’exemple ci dessus permet de vérifier si l’utilisateur est connecté, bien entendu on peut vérifier n’importe quelle attribution grace à cette méthode. Le test d’existence de l’utilisateur permet d’éviter des pages cassées en environnement de production. (Ce test permet de vérifier si l’utilisateur est authentifié, il accepte les cas où il est authentifié grâce à un cookie « se souvenir de moi »)

{{ is_granted(role, object, field) }}

Un objet peut être passé en option au système de vote.

Sachant cela il est facile de désactiver le bouton de suppression d’un utilisateur s’il n’a pas les attributions ROLE_SUPER_ADMIN

                            {% if app.user and is_granted("ROLE_SUPER_ADMIN") %}
                            <button class="btn btn-danger" type="submit"><i class="fa fa-trash-o"></i> {{ 'Delete'|trans({}, 'admin') }}</button>
                            {% endif %}

Continuez, nous allons voir comment gérer l’interdiction au niveau du contrôleur…

Fixer les permissions d’accès au niveau des contrôleurs et des routes

L’utilisateur est stocké en session, dans le contexte de l’application et il peut tout d’abord s’avérer judicieux de le récupérer dans une action.

Avant Symfony 2.6

// Vérifier que l'utilisateur est authentifié puis...
$user = $this->container->get('security.context')->getToken()->getUser();

Depuis Symfony 2.6

    // Vérifier que l'utilisateur est authentifié puis...
    $user = $this->getUser();

    // c'est un raccourcit pour ceci...
    $user = $this->get('security.token_storage')->getToken()->getUser();

Toujours vérifier avant d’utiliser l’objet utilisateur que l’utilisateur est connecté, sinon l’objet pourrait être vide !

    if (!$this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) {
        throw $this->createAccessDeniedException();
    }

    $user = $this->getUser();

Les permissions se vérifient au niveau du composant de sécurité ! Avant Symfony 2.6 on peut vérifier qu’un utilisateur possède une attribution particulière dans une action en récupérant le service ‘security.context’, grâce à la méthode isGranted(), depuis Symfony 2.6 un nouveau service ‘security.authorization_checker’ a été introduit à cette fin.

Avant :

   use Symfony\Component\Security\Core\Exception\AccessDeniedException;
   // ...
    $securityContext = $this->container->get('security.context');
    if (!$securityContext->isGranted('CONTENT_EDIT')) {
        throw new AccessDeniedException('Vous ne possédez pas les droits suffisants pour accéder à la page!');
    }

Après :

$this->denyAccessUnlessGranted('CONTENT_EDIT', null, 'Vous ne possédez pas les droits suffisants pour accéder à la page!');

Dans les deux cas une exception AccessDeniedException() est soulevée, qui déclenche une réponse HTTP 403.

En détail dans la documentation Symfony.

Cette méthode requiert du code et interfère quelques peu avec des lignes de code plus utiles. Heureusement, il est possible de sécuriser les contrôleurs directement au niveau des annotations grâce au bundle SensioFrameworkExtraBundle, cette méthode est préférable pour la lisibilité et elle est recommandée dans les meilleures pratiques Symfony2. (Attention, suivant vos besoins elle induit un fort couplage des contrôleurs au Framework, et introduit un peu de magie)

// ...
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;

/**
 * @Security("has_role('ROLE_ADMIN') and is_granted('POST_SHOW')")
 */
public function helloAction($name)
{
    // ...
}

La première partie de l’expression has_role() diffère de is_granted() dans le sens où ce dernier passe tout le processus de votes pour vérifier l’attribution alors que le premier vérifie si l’utilisateur possède un rôle.

Les annotations permettent d’utiliser des expressions complexes où sont manipulés objets utilisateurs et paramètres de la route, il est possible de se référer à la documentation pour plus d’exemples.

A présent il nous est facile interdire la suppression d’un utilisateur au niveau du contrôleur afin que seul le super administrateur puisse utiliser cette fonctionnalité.

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
...
    /**
     * Deletes a User entity.
     *
     * @Security("has_role('ROLE_SUPER_ADMIN')")
     * @Route("/{id}/delete", name="admin_users_delete", requirements={"id"="\d+"})
     * @Method("DELETE")
     */
    public function deleteAction(User $user, Request $request)
    {
    ...
    }

Gérer les permissions grâce au système d’électeurs

Sous Symfony2 il est possible de vérifier les permissions d’accès en utilisant un module qui gère les ACL mais c’est une solution très lourde pour des cas d’utilisation très rares et dans la majorité des situations l’emploi d’un système plus simple est préférable, ce système consiste à mettre en place des élections auprès d’objets dédiés à vérifier des attributions, sous Symfony ces objets s’appellent des Voters. Documentation sur les électeurs.

Partons d’un contrôleur pour comprendre l’utilisation d’électeurs dans la détermination des permissions.

$this->denyAccessUnlessGranted('ROLE_USER', null, 'Vous ne possédez pas les droits suffisants pour accéder à la page!');

Lorsque l’on vérifie la permission (ROLE_USER dans l’exemple, cela s’applique également a is_granted()), Symfony2 ne se contente pas de vérifier si l’utilisateur possède ce rôle mais il passe l’attribut ROLE_USER à un certain nombre d’électeurs (objets Voters) et demande à chacun de voter si oui ou non l’utilisateur devrait se voir accorder la permission ROLE_USER.

Symfony2 possède 3 électeurs par défaut :

  • RoleVoter vote seulement si l’attribut commence par les caractères ‘ROLE_’ et vérifie si l’utilisateur possède exactement cet attribut en rôle
  • RoleHierarchyVoter vote seulement si l’attribut commence par les caractères ‘ROLE_’ et vérifie si l’utilisateur possède cet attribut en utilisant la hiérarchie de rôles (l’héritage de rôles est défini dans la configuration de la sécurité, fichier security.yml)
  • AuthenticatedVoter vote seulement si l’attribut est IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED ou IS_AUTHENTICATED_ANONYMOUSLY

 

Si l’on passe un autre type d’attribut à is_granted(), aucun de ces électeurs ne votera et la méthode retournera une valeur booléenne faux, à moins d’avoir créé nos propres électeurs qui répondent à ce nouvel attribut. Il est également possible de passer un second argument à is_granted (qui remplace la valeur null dans l’exemple plus haut), qui est n’importe quel type d’objet. Chaque électeur se voit passer l’objet et peut prendre sa décision d’accès en s’appuyant sur des données spécifiques à l’objet : cette capacité de dire que tel utilisateur a accès en édition à tel objet permet d’établir des règles d’attribution complexes.

Au niveau de l’administration, le mieux est de ne pas mélanger les genres, un administrateur doit être responsable, et je n’ai pas de cas d’utilisation… mais maintenant vous pouvez à peu près tout faire !

Synchroniser les utilisateurs avec un annuaire LDAP

Les entreprises ou organisations se dotent de services d’annuaires LDAP pour conserver les informations relatives à leur organisation interne dès qu’elles atteignent une dimension respectable. Une des problématiques qui se pose lorsque l’on intègre une application dans le système d’information de l’entreprise est celle de la multiplication des identifiants de connexion, et de la synchronisation des utilisateurs avec l’annuaire d’entreprise. Nous allons tenter de palier à ces besoins.

Une très brève introduction à LDAP

LDAP est un protocole standard qui permet de gérer des annuaires, il fournit les méthodes d’accès aux données d’annuaires qui sont généralement des informations relatives à aux organisations, et permettent de recenser des utilisateurs, du matériel, etc…

Les possibilité offertes par LDAP incluent la connexion, la recherche d’informations, l’insertion/modification ou encore suppression d’entrées dans l’annuaire.

Les services d’annuaires LDAP sont nombreux, Microsoft Active Directory (AD) est la mise en oeuvre sur le serveur Windows qui permet d’identifier et d’authentifier les utilisateurs sur le réseau (ce n’est qu’une partie des nombreuses entrées mais c’est ce qui nous intéresse pour cette étude), on peut encore citer les serveurs OpenLDAP ou Apache Directory Server… l’essentiel c’est que ces serveurs respectent le protocole et répondent à une norme pour les systèmes d’annuaires : l’intégration de l’indentification et de l’authentification de l’utilisateur au travers de tel ou tel serveur ne sera au final que du paramétrage dans notre application.

Nous ne dépenserons pas une semaine à installer un annuaire LDAP de test… vous pouvez trouver sur internet des personnes bien avisées qui ont mis en place des serveurs que l’on peut exploiter dans ce seul but.

A quoi ressemble un annuaire LDAP ?

Les données d’annuaire sont sous forme de structure arborescente dont chaque nœud est constitué d’attributs et de valeurs, les attributs sont définis dans des schémas qui définissent leur type.

Wikipédia dispose d’un article assez complet sur LDAP, et je vais aller à l’essentiel pour effectuer un test de connexion et voir comment l’utilisateur est dupliqué après connexion dans notre propre application.

Les éléments de base des annuaires utilisent en général une nomenclature similaire, dc=… (domain component) pour la racine de l’annuaire et ses premières branches, ou= (organizational units) pour les organisations ou groupes, cn= (common name) pour le nom d’une entrée (çàd un objet abstrait un paramètre de configuration ou bien concret comme du matériel informatique), ou encore uid (user identifier) pour une personne.

Chaque entrée de l’annuaire possède un identifiant unique, DN (Distinguished Name), qui est composé de son RDN (Relative Distinguished Name) suivi du DN de son parent. Le RDN est généralement l’attribut uid pour une personne (user identifier) : le DN représente donc par récursivité le nom de l’entrée sous la forme de son chemin d’accès de la dernière branche jusqu’au sommet de l’arbre

Les exemples de DN (reportez ous à Wikipédia)

cn=ordinateur,ou=machines,dc=EXEMPLE,dc=FR
cn=Jean,ou=gens,dc=EXEMPLE,dc=FR

Nous en savons peu mais bien suffisamment pour avancer…

Serveur LDAP de test

Le plus compliqué chaque fois que l’on doit intervenir en développement sur une authentification LDAP est de disposer d’un serveur qui reflète fidèlement le serveur de production. Nous allons nous contenter d’être simple utilisateurs de services à notre disposition sur le web.

Quelques recherches internet ont suffi à trouver une société qui met à disposition un serveur LDAP de test. Bien entendu ce serveur pourra ne plus être hébergé dans quelques temps, mais il sera parfait pour mes propres tests, et je ne doutes pas que d’autres personnes fournissent les mêmes services ailleurs si celui ci venait à fermer.

La société préconise d’utiliser Apache Directory Studio comme client LDAP, ce que j’ai fait avec les paramètres de connexion fournis. Au final j’abouti à une connexion fonctionnelle et je peux naviguer dans la structure du répertoire, vous pouvez consulter la copie d’écran où apparaît notre jeu de données d’essai.

ldap

Authentification LDAP avec Symfony2 et FOSUserBundle

Le support LDAP sera du ressort de l’extension FR3DLdapBundle.

Avant d’installer le bundle, assurez vous d’avoir activé l’extension PHP LDAP dans le fichier de configuration de PHP. L’installeur vérifie la dépendance à cette extension et s’arrête si elle n’est pas satisfaite

Pour l’installation, commencez par télécharger les sources :

composer require "fr3d/ldap-bundle"

Puis intégrez le bundle au sein de l’application dans le fichier AppKernel.php :

...
new FR3D\LdapBundle\FR3DLdapBundle(),
...

Un utilisateur doit pouvoir se connecter depuis l’application ou depuis l’annuaire, et le mécanisme d’authentification utilise un seul fournisseur d’utilisateur, le premier déclaré dans la configuration.
Pour paramétrer correctement l’application ce fournisseur doit enchaîner les fournisseurs déclarés en aval, on déclare un fournisseur pour chaque bundle (fos_userbundle et fr3d_ldapbundle) qui seront exploités tous les deux pour récupérer un utilisateur. Le bundle LDAP doit impérativement être appelé en dernier dans la chaîne.

Editez le fichier app/config/security.yml

...
    providers:
        chain_provider:
            chain:
                providers: [fos_userbundle, fr3d_ldapbundle]
        fr3d_ldapbundle:
            id: fr3d_ldap.security.user.provider
        fos_userbundle:
            id: fos_user.user_provider.username_email

    firewalls:
   ...
        main:
      ...
            fr3d_ldap: ~
...

Pour fonctionner, vous devez paramétrer le FR3DLdapBundle dans le fichier de configuration config.yml, ce n’est pas la partie la plus simple car la documentation n’est pas à la hauteur de bundle. A l’heure où j’écris ces lignes je suis bien seul à fournir un exemple de configuration qui fonctionne sur le LDAP de Forumsys , après une demi journée d’essais vains, c’est une pépite !

fr3d_ldap:
    driver:
       host:     ldap.forumsys.com
       port:     389
       bindRequiresDn:      true
       baseDn:   cn=read-only-admin,dc=example,dc=com
       password: password
    user:
        baseDn: dc=example,dc=com
        filter: (&(objectClass=person))
        attributes:
           - { ldap_attr: uid,  user_method: setUsername }
           - { ldap_attr: mail, user_method: setEmail }
    service:
        ldap_manager:  app.ldap.ldap_manager

Les lignes host, baseDn et password sont les attributs de connexion fournis par Forumsys. Dans la section user on peut préciser le nom de domaine où trouver les utilisateurs (un niveau plus haut dans l’arborescence) et surtout on peut appliquer un filtre sur les attributs LDAP. L’annuaire que l’on interroge est un OPENLDAP en version 3, la plupart des exemples disponibles sur internet interrogent un ACTIVE DIRECTORY, le schéma qui décrit les attributs est sensiblement différent d’un serveur à l’autre et vous devez le personnaliser, ici vous pouvez vous référer à la copie d’écran pour voir la mise en correspondance du filtre avec le schéma.

Lorsque l’utilisateur est trouvé dans l’annuaire LDAP, une entité utilisateur est hydratée à partir des attributs de l’annuaire. La correspondance des attributs aux méthodes de l’entité utilisateur est définie dans la configuration. Je n’ai repris que les attributs uid et mail de l’annuaire (les méthodes setUsername() et setEmail() sont définies dans la classe de base de FOSUserBundle). L’uid est l’identifiant utilisateur, il est mis en correspondance avec la propriété username : comme il apparaît en premier dans la configuration des attributs c’est sur lui que la recherche dans l’annuaire sera effectuée. (On pourrait vouloir se connecter par e-mail)

Dans l’entité utilisateur vous pourriez créer une propriété pour chaque information du répertoire LDAP que vous souhaitez voir dupliquée dans la base de données, certains enregistrent le nom de domaine, je n’en ai pas l’utilité.

A ce stade vous pouvez vous connecter avec un des identifiants utilisateur, le mot de passe est toujours password.

Connectez vous par exemple avec les comptes de Marie Curie ou Louis Pasteur :

</pre>
Identifiant : [<strong>curie</strong>|<strong>pasteur</strong>]
Mot de passe : [<strong>password</strong>]
<pre>

Que se passe-t-il après connexion ?

FOSUserBundle ne peut pas fournir d’utilisateur qui correspond à l’identifiant et au mot de passe, par contre l’annuaire LDAP lui est en mesure, ce qui déclenche l’hydratation d’un utilisateur qui est en théorie persisté en base : l’utilisateur est dupliqué dans la table utilisateur de l’application et ses propriétés sont manipulables dans notre interface d’administration. A la prochaine connexion, l’utilisateur sera toujours identifié par le LDAP mais les propriétés de l’utilisateurs seront renseignées depuis la table utilisateur, pas depuis l’annuaire.

En théorie… car en pratique il peut vous rester un détail à régler, du moins sur une base de données où sont gérées les contraintes : PostgreSQL soulève une exception car le programme essaie d’insérer une valeur NULL dans la colonne du mot de passe qui est définie à NOT NULL.

Lors de l’hydratation de l’utilisateur le gestionnaire LDAP fixe la propriété mot de passe à la chaîne de caractère vide, ce qui est dérangeant par rapport à la méthode setPassword() de l’utilisateur telle que définie chez moi.

    public function setPassword($password) {
        if ($password)
            $this->password = $password;
        return $this;
    }

Ce setter n’accepte pas une chaîne vide comme valeur de mot de passe valide, et vous êtes nombreux à posséder le même.

Une solution évidente est de modifier le setter pour accepter les chaînes vide, une autre solution est de surcharger le gestionnaire LDAP pour insérer une chaîne quelconque lors de l’hydratation. Vous pouvez aussi cumuler les deux.

Dans les deux cas le mot de passe n’est pas encrypté, il n’y aura qu’une chaîne de caractère vide ou une chaîne prédéfinie dans la base par conséquent il ne pourra pas y avoir de mise en correspondance lors de l’authentification par le fournisseur FOSUserBundle, seul le fournisseur FR3DLdapBundle retournera l’utilisateur de la base après réussite de connexion à l’annuaire.

Solution 1.

    public function setPassword($password) {
        if ($password !== null)
            $this->password = $password;
        return $this;
    }

Solution 2.

Pour surcharger le gestionnaire LDAP il faut déclarer un service (fichier services.yml par ex.)

services: 
    ...
    app.ldap.ldap_manager:
        class: 'AppBundle\Ldap\LdapManager'
        arguments: ['@fr3d_ldap.ldap_driver', '@fr3d_ldap.user_manager', '%fr3d_ldap.ldap_manager.parameters%']

Créez la classe correspondante sous AppBundle\Ldap\Ldapmanager.php

<?php namespace AppBundle\Ldap; use FR3D\LdapBundle\Ldap\LdapManager as BaseLdapManager; use Symfony\Component\Security\Core\User\UserInterface; class LdapManager extends BaseLdapManager { protected function hydrate(UserInterface $user, array $entry) { parent::hydrate($user, $entry); $user->setPassword('LDAP_AUTH_ONLY');
    }
}

Cette dernière méthode permet de personnaliser comment seront renseignées les propriétés utilisateur, il peut être utile de se baser sur l’appartenance à un service ou une organisation dans les attributs LDAP pour accorder des privilèges par exemple ($user->setGroups() ou $user->setRoles())

Finalement, n’oubliez pas que si vous modifiez le mot de passe au travers de l’interface administrateur, FOSUserBundle reprendra le dessus à la connexion.

Maintenant que se passe-t-il si l’entrée utilisateur créé un doublon ? La tentative d’insertion d’un utilisateur ldap avec un nom d’utilisateur ou un e-mail déjà en base soulève une exception.

Afin de pouvoir maîtriser le rendu de la page de connexion et informer l’utilisateur il est nécessaire de prendre le contrôle de ces exceptions, et dans l’immédiat le bundle ne permet pas de le faire proprement. Cependant comme il est couplé à FOSUserBundle, il utilise le gestionnaire d’utilisateurs de ce dernier… et comme on a déjà surchargé le gestionnaire d’utilisateurs, il nous suffit d’attraper les exceptions lancées par Doctrine et propager une exception d’authentification qui sera interceptée lors du contrôle d’authentification par le gestionnaire de sécurité. Cette exception sera interceptée et affichée proprement sur notre formulaire de connexion.

Créez une exception qui étend les exceptions d’authentification Symfony sous AppBundle\Exception\DuplicateUserException.php

<?php

namespace AppBundle\Exception;

use Symfony\Component\Security\Core\Exception\AuthenticationException;

class DuplicateUserException extends AuthenticationException
{
    /**
     * {@inheritdoc}
     */
    public function getMessageKey()
    {
        return 'Un utilisateur possédant le même nom ou la même adresse e-mail existe déjà';
    }
}

Puis modifiez votre surcharge d’écouteur de connexion pour intercepter les exceptions provoquées par une entrée dupliquée en cas d’ajout ou de modification de l’utilisateur :

<?php

namespace AppBundle\Listener;

use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Event\UserEvent;
use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use AppBundle\Exception\DuplicateUserException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;

class LoginListener implements EventSubscriberInterface {

    protected $userManager;
    
    public function __construct(UserManagerInterface $userManager) 
    {
        $this->userManager = $userManager;
    }
    
    public static function getSubscribedEvents()
    {
        return array(
            FOSUserEvents::SECURITY_IMPLICIT_LOGIN => 'onImplicitLogin',
            SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
        );
    }

    protected function updateUser($user) {
        try {
            if (!$user->getLoginCount())
                $user->setFirstLogin(new \DateTime());

            $user->setLoginCount((int) $user->getLoginCount() + 1);

            $this->userManager->updateUser($user);
        }
        catch (UniqueConstraintViolationException $e) {
            throw new DuplicateUserException();
        }
    }
    
    public function onImplicitLogin(UserEvent $event)
    {
        $this->updateUser($event->getUser());
    }
    
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) {
        $user = $event->getAuthenticationToken()->getUser();
        //if ($user instanceof UserInterface)
            $this->updateUser($user);
    }
}

Pour conclure ce sujet

Retenez la leçon, ne chiffrez plus une gestion d’utilisateur à la légère dans vos évaluations projet !

Représenter un résultat de requêtes spatiales sous PostgreSQL PostGIS depuis un client web

Dans l’objectif de développer une application cartographique, nous avons au niveau des données récupéré des jeux de données spatiales, installé la base de données et son extension spatiale et finalement chargé les données en base. Nous avons également mis en place un serveur cartographique qui fournit les données au travers du réseau, et construit les bases de l’application dans un client web pour interroger le serveur cartographique et afficher les premiers éléments de carte.

L’application ne se contente pas d’afficher des données statiques, mais elle doit permettre de représenter l’information dynamiquement en effectuant des requêtes en fonction du contexte d’utilisation. C’est la partie que nous allons développer dans ce document.

Préparation de l’interface cartographique

La première étape est de préparer l’interface graphique et recadrer le contexte.

Nous avons vu comment appeler les services WMS et WFS que nous avons mis en place pour générer une carte simple où la proximité des établissements scolaires aux parcelles cultivées sur la Gironde était affichée de façon statique sur une carte rendue disponible dans un navigateur web par les librairies Leaflet et OpenLayers 3.

Dans la suite de ce document j’utiliserai la librairie Leaflet mais le principe est identique pour OpenLayers 3 et le code peut être adapté.

Question de volumétrie nous n’avons pas sur ce prototype intégré les données parcellaires sur toute les régions. Voyons en premier lieu comment adapter le code pour afficher les parcelles cultivées sur la France métropolitaine, puis explorons les fonctionnalités à paramétrer sur la carte.

Représentations des couches cartographiques depuis le serveur cartographique

Premièrement le programme représente le fond de carte par l’appel aux serveurs de tuiles d’OpenStreetMap, puis il fait appel au serveur QGIS Server pour récupérer les données géométriques des départements français dont il effectue le rendu à la volée. Ces fonctionnalités sont identiques à celles du premier prototype.

Ensuite le programme effectue le rendu des parcelles pour la France métropolitaine. Il interroge le serveur WMS qui génère une image pour la vue en cours.

var wmsLayerParcelles = L.tileLayer.wms(project, {
    layers: 'Parcelles',
    format: 'image/png',
    transparent: true,
    minZoom: 10,
    maxZoom: 18,
    opacity: 0.55,
});
wmsLayerParcelles.addTo(map);

Cette fonctionnalité a été expliquée, la nouveauté est l’apparition des paramètres minZoom et maxZoom qui vont permettre de conditionner le rendu de la couche au niveau d’agrandissement en cours sur la carte. En d’autres termes la librairie ne fera pas appel au serveur pour représenter les parcelles en dessous d’un niveau d’agrandissement fixé à 10. Cela permet de limiter la quantité d’informations de parcelles sur une image, et donc à la fois le travail de représentation des données du serveur et le trafic sur le réseau où seraient transférées des images trop volumineuses.

Si le rendu est effectué côté serveur, on souhaite également accéder aux caractéristiques des parcelles pour indiquer le numéro d’îlot et le type de culture. Cela peut être fait à un niveau d’agrandissement encore supérieur afin de limiter la taille des fichiers échangés, en faisant appel au service WFS.

map.on('zoomend moveend', function() {
...
    if (map.getZoom() > 15)
        getJsonLayerParcelles(map.getBounds());
});

On va préciser au gestionnaire d’événements de mettre en écoute les opérations d’agrandissement et de glisser-déposer.

Lorsque ces événements arrivent, on vérifie le niveau d’agrandissement de la carte et s’il est supérieur à la valeur seuil que l’on a choisi pour faire figurer les données, on charge la couche vectorielle de façon asynchrone pour l’étendue géographique en cours sur la carte.

var jsonLayerParcelles = null;

function getJsonLayerParcelles(bounds) {

    var getFeatureParcelles = project + '?SERVICE=WFS'
        + '&VERSION=1.1.0'
        + '&REQUEST=GetFeature'
        + '&TYPENAME=Parcelles'
        + '&SRSNAME=EPSG:4326'
        + '&outputFormat=GeoJSON'
        + '&BBOX=' + bounds.toBBoxString();

    var proxy = host + '/proxy.php?callback=getGeoJson&url=';

    var proxyURL = proxy + encodeURIComponent(getFeatureParcelles);

    var jsonLayerParcelles = L.geoJson(null, {
        style: function (feature) {
            return {
            fillColor: 'rgb(255,255,255)',
            color: 'rgb(175,179,138)',
            weight: 1,
            opacity: 1,
            fillOpacity: 0,
            dashArray: '1,5',
            };
        },
        onEachFeature: function (feature, layer) {
            var content = 'Parcelle N° ' + feature.properties['num_ilot'] + '<br />';
            content += getCulture(feature.properties['cult_maj']);
            layer.bindPopup(content);
        }
    });

    $.ajax({
        url: proxyURL,
        dataType: 'jsonp',
        jsonpCallback: 'getGeoJson',
        success: function (response) {
            L.geoJson(response, {
                onEachFeature: function (feature, layer) {
                    jsonLayerParcelles.addData(feature)
                }
            });
        }
    });
    jsonLayerParcelles.addTo(map);
}

Le principe de chargement de la couche vectorielle est le même que pour les départements sauf que cette fois-ci on passe l’étendue géographique dans l’url du service. Cela se fait via deux opérations, map.getBounds() et bounds.toBBoxString().

Remarquez que pour chaque objet récupéré on attache un contenu qui s’affichera dans un popup au clic sur la couche. Le contenu est mis en forme par rapports aux propriétés récupérées.

Comme le rendu est déjà effectué sous forme d’image côté serveur, l’opacité est fixée pour rendre transparent le remplissage des polygones, le contour est quand à lui représenté en pointillés grâce à l’option de style dashArray. Cela permet de faire figurer les parcelles qui ont été représentées en blanc opaque ou transparence car ce ne sont pas des cultures mais des prairies ou des gels de cultures.

Finalement par rapport au premier prototype, un contrôle a été ajouté pour utiliser le géocodage d’adresses afin de localiser une destination sur la carte.

        <link rel="stylesheet" href="css/Control.OSMGeocoder.css" />
        <script src="js/Control.OSMGeocoder.js"></script>
var osmGeocoder = new L.Control.OSMGeocoder({
    collapsed: false,
    position: 'topright',
    text: 'Allez!'
});
osmGeocoder.addTo(map);

Paramétrage de fonctionnalités sur la carte

Une extension à la librairie Leaflet permet de faire figurer sur la carte une barre latérale qui a la capacité de se déplier et replier au clic sur les icônes de la barre.

J’ai adapté les styles css pour effectuer un rendu qui laisse entrevoir le fond de carte par transparence.

L’extension est disponible sous Leaflet comme sous Openlayers, elle me semble un bon choix relativement standardisé pour apporter des fonctionnalités à la carte sans développer une multitude de contrôles.

L’extension requiert le chargement de ressources css et javascript, ainsi que du jeux d’icônes vectorielles sous police de caractères font-awesome. Je fais également appel aux polices de caractères Google pour le rendu du contenu de la barre latérale.

        <link href='http://fonts.googleapis.com/css?family=Lato:400,700|PT+Sans:400,700|Roboto:400,500,700' rel='stylesheet' type='text/css'>
        <link href="http://maxcdn.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.min.css" rel="stylesheet">
...
        <script src="js/leaflet-sidebar.min.js"></script>

L’extension requiert de modifier la classe de style appliquée au conteneur de carte.

<div id="map" class="sidebar-map"></div>

Le contenu de la barre est simplement mis en place par une convention de balises html que je ne reprendrai pas ici, l’extension étant bien documentée.

Un onglet de paramètres permettra à l’utilisateur de configurer la carte. En termes de fonctionnalités on souhaite à minima paramétrer la distance qui caractérise la proximité des établissements aux parcelles, d’autres fonctionnalités peuvent permettre de faire une sélection des établissements à représenter en fonction de la surface des parcelles en contact, des types de cultures, etc…

Ces fonctionnalités requierent d’interroger la base de données après soumission d’un formulaire, l’interrogation se fait au moyen de requêtes spatiales sur Postgis, sachant que l’appel s’effectue en Ajax et que le serveur retournera des informations en Json.

Requêtes spatiales

Avant de passer au mécanisme de communication entre la carte et la base de données, nous allons procéder à l’étude des requêtes spatiales qui feront toute l’importance des données représentées.

La détermination de risques est une problématique complexe, ce que l’application propose n’est pas d’évaluer un risque mais d’exposer des facteurs de risques potentiels qui découlent simplement du bon sens. La démarche est de déterminer des populations d’élèves qui pourraient être impactées par ces critères afin de construire des échantillons de population sur lesquels effectuer des prélèvements pour évaluer l’exposition effective aux pesticides.

Intuitivement le risque d’exposition aux pesticides augmente quand :

  • L’aire cultivée augmente à proximité de la population. Une requête spatiale doit opérer un calcul d’aire. L’aire peut être représentée par un symbole proportionnel.
  • Il existe plusieurs types de cultures différentes à proximité d’une population. (multiplication de la nature des pesticides, et des horaires d’épandage). Une requête doit permettre de comptabiliser les cultures à proximité d’une population
  • La catégorie de culture est plus ou moins consommatrice de pesticides. Un indicateur existe, l’IFT, qui permet de mesurer la pression phytosanitaire (quantités et fréquences d’épandage) d’un sol mais il ne prend pas en compte la toxicité des pesticides, le type de culture sera par conséquent affiché seulement à titre informatif, il donne quand même une information exploitable car on a connaissance par exemple de la toxicité des traitements en viticulture. Les requêtes doivent éliminer les pâturages et gels de cultures

La carte sera imparfaite. Un certain nombre de paramètres influeront les résultats, par exemple :

  • Il n’est pas possible de prendre en compte la rotation des cultures. Les données de parcelles sont des déclarations relevées en 2010, ces données sont anciennes, et comportent nécessairement des erreurs. Un parallèle pourra être mis en place avec une carte aérienne.
  • Les cultures bio sont négligées faute de données. (De l’ordre de 3% des cultures)
  • Le centroide des établissements n’est pas l’aire des bâtiments, si l’on considère une proximité de 500 mètres aux coordonnées de latitudes et longitude de l’établissement, la proximité réelle aux salles de classe ou à la cours de récréation peut être moindre
  • Les vents dominants et l’age du proviseur ne sont pas pris en compte !

Fonctions spatiales PostGIS, géométrie ou géographie ?

Consultez la référence PostGIS !

PostGIS dispose d’un jeu très complet de fonctions spatiales, et l’on abordera que très peu d’entre elles. Sachez toutefois que PostGIS change de convention de nommage pour standardiser le nom des fonctions par l’ajout du préfixe spatial ST_ à chaque fonction. Cela signifie que si vous recherchez des exemples d’utilisation sur Internet il vous faudra probablement retirer le préfixe…

Les fonctions spatiales peuvent pour certaines fonctionner avec plusieurs types de paramètres. Des conversions (implicites ou explicites) peuvent être effectuées entre certains types. Les types de paramètres sont :

  • box2d : est une boîte composée de coordonnées xmin, ymin, xmax, ymax. Souvent utilisé pour retourner la boîte englobante 2D d’une géométrie.
  • box3d : est une boîte composée de coordonnées xmin, ymin, zmin, xmax, ymax, zmax. Souvent utilisé pour retourner la mesure 3D d’une géométrie ou le recouvrement des géométries.
  • geometry : est le type de données spatiales utilisé par PostGIS pour représenter une fonction dans le système de coordonnées Euclidien. L’unité est celle du SRID.
  • geometry_dump : est un type de données spatiales avec deux champs, geom et path[] qui sont respectivement un objet de géométrie et un tableau qui contient la position de la géométrie dans l’objet de dump
  • geography : est un type de données spatiales utilisée pour représenter une entité dans le système de coordonnées sphériques terrestres. L’unité est le mètre carré.

Nous utiliserons les types geometry et geography. Nos données sont enregistrées dans le type geometry et pour nos besoins nous voudrons calculer des distances en mètres ou des superficies en mètres carrés, cela implique de faire des conversions de type geometry vers type geography et vice versa.

geom::geography
geom::geometry

Sont des exemples de conversions explicites. (Vous pouvez vous référer à la documentation pour savoir quels types peuvent être convertis en autres types)

Dernière remarque concernant les types, l’unité géographique d’une superficie est le mètre carré. Pour des raisons évidentes nous emploieront plus volontiers le kilomètre carré ou l’hectare pour donner une information.

Les conversions d’unités sont les suivantes :

1 mètre carré = 1 * 10^-6 kilomètre carré   (ou encore 1 / 1000000)
1 mètre carré = 1 * 10^-4 hectare   (ou encore 1 / 10000)

Préparation des requêtes

Calcul de l’aire d’un polygone

ST_Area – Retourne l’aire de la surface d’un polygone ou multi-polygone.

Pour l’exemple je vais calculer l’aire de chaque département. La colonne est de type géométrique, il suffit de faire une conversion de type pour retourner la surface en mètres carrés.

SELECT nom_dept, ST_Area(CAST(geom As geography)) * POWER(10, -6) as geom FROM "Departement";

Distance entre géométries

La recherche d’établissements à proximité de zones cultivées peut être réalisée au travers de plusieurs fonctions, le problème qui va se poser est celui des performances.

ST_DWithin – Renvoie la valeur vraie si les géométries sont dans la distance spécifiée l’une à l’autre. Pour les unités géographiques la distance est exprimée en mètres, la mesure s’effectue par défaut autour d’un sphéroïde.

ST_Distance – Pour des champs de type géographie, retourne la distance minimale entre deux géographies sphéroïdale en mètres.

ST_Buffer – Pour un champ de type géométrie, retourne une géométrie qui représente tous les points dont la distance à l’objet géométrique est inférieure ou égale à la distance. Les calculs sont dans le système de référence de l’objet. Pour une géométrie de type point le résultat sera approximativement un cercle ayant pour centre le point et pour rayon la distance.

ST_Intersects – Retourne la valeur vraie si les géométries / géographie « se croisent spatialement en 2D » – (possèdent une partie de l’espace en commun) et la valeur faux sinon (les objets sont disjoints). Pour la géographie – la tolérance est 0,00001 mètres (donc tous les points qui sont considérés fermés retournent la valeur vraie)

Si l’on s’appuie sur les deux premières fonctions, il suffit en théorie de faire une jointure de la table d’établissement avec la table (ou la vue matérialisée) des cultures sur la géométrie des objets trans-typée en géographie sur la distance souhaitée. L’approche est intuitive mais n’exécutez pas la requête qui suit…, pour une proximité de 50 mètres on peut tenter d’évaluer le temps d’exécution :

EXPLAIN SELECT 
e.numero_uai, e.denominati, e.geom, c.num_ilot, c.cult_maj
FROM "Etablissement" e, "master_cultures" c 
WHERE ST_DWithin(e.geom::geography, c.geom::geography, 50);

Le coût d’exécution est estimé à :

"Nested Loop  (cost=0.00..119895256281.96 rows=132693 width=104)"

La requête prendrait un temps indécent à s’exécuter !

Pour une utilisation en ligne il est impératif de compiler au préalable les données. Ma première idée est d’établir un distancier entre le centroide des établissements et les parcelles de cultures à moins de 1500 mètres, le seuil de distance maximale qui sera proposé à l’utilisateur.

En volumétrie, nous avons 64.901 établissements et 6.132.686 parcelles, soit sans seuil potentiellement 398.017.454.086 lignes !

EXPLAIN 
SELECT count(e.numero_uai)
FROM "Etablissement" e, "master_cultures" c
WHERE ST_DWithin(e.geom::geography, c.geom::geography, 1500);
"Aggregate  (cost=119116282174.69..119116282174.70 rows=1 width=9)"

Cette approche permettrait d’enregistrer les distances dans une table indexée mais cette opération est toujours indécente si l’on considère le temps d’exécution initial.

Une telle quantité de parcelles cultivées est très pénalisante, peut-être peut-on éliminer certaines parcelles de l’équation ? Le type de culture est donné par un numéro. On peut éventuellement éliminer certains types, comme les prairies, qui ne sont pas à priori à prendre en considération.

select count(num_ilot) from master_cultures
where cult_maj not in (18, 19);

Retirer les prairies et prairies temporaires permet de diviser par deux le nombre de lignes de la table de cultures, cela reste toutefois très insuffisant.

Il faut envisager une autre approche. Si le coût d’une opération ST_DWithin() est trop important peut être que l’opération ST_Intersects() se révélera plus rapide.

Si l’on se base sur le centroide des établissements pour créer une zone tampon de 1500 mètres autour de l’établissement on peut chercher l’intersection entre la zone tampon et les parcelles, puis on pourra rechercher la distance sur les cas limités où la proximité est avérée.

create table tampon_etas as
select e.numero_uai, e.geom as centre_geom, ST_Buffer(e.geom::geography, 1500)::geometry as geom 
FROM "Etablissement" e;

La première opération est triviale, il suffit d’employer la fonction ST_Buffer() autour de la géométrie de l’établissement, de type POINT, sur un rayon de 1500 mètres pour créer une nouvelle géométrie. Cette opération prend environ 13 secondes, suite à quoi on peut ajouter des indexes spatiaux. La géométrie du point est conservée, elle matérialise le centre de la zone tampon, à partir duquel sera calculée la distance aux cultures.

CREATE INDEX tampon_cgeom_gist ON tampon_etas USING gist (centre_geom) TABLESPACE pg_default;
CREATE INDEX tampon_geom_gist ON tampon_etas USING gist (geom) TABLESPACE pg_default;

Tentons d’évaluer l’opération d’intersection…

EXPLAIN SELECT t.numero_uai
FROM "tampon_etas" t, "master_cultures" c 
WHERE ST_Intersects(t.geom, c.geom);
"Nested Loop  (cost=0.28..176252434.15 rows=132693478 width=9)"

Le coût est très élevé mais déjà divisé par 1000 par rapport aux premières approches. Nous pouvons tenter d’exécuter une requête sur les cultures d’un seul département, et multiplier le temps d’exécution par le nombre de départements pour avoir une estimation du temps d’exécution.

SELECT t.numero_uai
FROM "tampon_etas" t, "cultures_01" c 
WHERE ST_Intersects(t.geom, c.geom)
AND cult_maj not in (18, 19);

Malheureusement l’opération retourne une erreur !

ERREUR:  GEOSIntersects: TopologyException: side location conflict (...)

L’opération n’abouti pas car certaines données de la table de cultures sont invalides, PostGIS possède des fonctions qui permettent de vérifier les données, ou encore de réparer des données.
Si le taux d’erreur n’est pas trop élevé on pourra ignorer les parcelles dont les données sont en erreur.

ST_IsValid – Teste une valeur géométrique et retourne la valeur vrai si la géométrie est bien formée.

ST_IsSimple – Teste une valeur géométrique et retourne la valeur vrai si la géométrie n’a pas de point géométrique invalide comme une auto-intersection ou auto-tangence.

Notez que la géométrie de la table des cultures est de type MULTIPOLYGON, mais que certaines de ses entrées sont de type GeometryCollection, il y a une incohérence de type pour certaines entrées.

SELECT count(*)
FROM "cultures_01" c 
WHERE cult_maj not in (18, 19)
AND NOT ST_IsValid(geom);

142 géométries se révèlent invalides sur 63962 géométries testées, soit environ 2 pour mille. Par rapport à notre besoin on peut se contenter d’ignorer les parcelles dont la géométrie est invalide, le résultat sera négligeable.

SELECT t.numero_uai, c.num_ilot,
t.geom as eta_geom, c.geom as cul_geom,
ST_Distance(t.centre_geom::geography, c.geom::geography) as distance
FROM "tampon_etas" t, "master_cultures" c
WHERE
ST_IsValid(c.geom)
AND ST_Intersects(t.geom, c.geom)
LIMIT 100;

Lorsque l’intersection entre la zone tampon et les cultures retourne la valeur vrai on peut récupérer la distance du centre du tampon à la géométrie de la culture. Cela souffre un peu d’imprécision, mais la fonction ST_DWithin() est contre performante dans ce cas. Il ne reste plus qu’à créer la table qui conservera les distances, c’est cette table que nous interrogerons.

explain CREATE TABLE intersect_etabs AS 
SELECT t.numero_uai, e.code_dept, c.num_ilot,
t.centre_geom as eta_tampon_geom, c.geom as cul_geom,
CAST(floor(ST_Distance(t.centre_geom::geography, c.geom::geography)) as smallint) as distance
FROM 
	"tampon_etas" t INNER JOIN "Etablissement" e on t.numero_uai = e.numero_uai, 
	"master_cultures" c
WHERE
c.cult_maj not in (18, 19) 
AND ST_IsValid(c.geom)
AND ST_Intersects(t.geom, c.geom);

La création prend 25 minutes… J’ai transformé la distance calculée afin d’utiliser un entier de petite dimension qui sera plus rapide à interroger qu’un nombre avec décimales.

Une jointure sur la table d’établissements permet de récupérer au passage le code du département.

select * from intersect_etabs where distance < 300;

La requête prend environ 33 secondes sur l’ensemble de la table sans utiliser d’étendue. Ce résultat n’est pas exploitable sur l’étendue de la France métropolitaine, d’une part parce que le résultat est trop long à retourner, d’autre part parce que cela ferait représenter trop d’établissements sur la carte.

La solution est d’exécuter la requête uniquement lorsque le niveau d’agrandissement est adéquat, on profite alors des capacités de l’indexe spatial. Au niveau de la vue sur la France entière, on pourra faire afficher au niveau de chaque département le nombre d’établissements à proximité de parcelles cultivées dans un symbole de cercle proportionnel. Nous devons au préalable compiler les informations pour les départements pour chaque option de distance proposée à l’utilisateur dans l’interface.

Vérifions en premier lieu la réactivité de la requête spatiale sur une étendue géographique limitée.

Limiter la requête spatiale PostGIS à une étendue géographique

ST_MakeEnvelope – Crée un polygone rectangulaire formé à partir des minimums et maximums donnés. Les valeurs en entrée doivent être spécifiées dans le système de coordonnées géographiques spécifié par le SRID.

Sous PostGIS on peut utiliser la fonction ST_MakeEnvelope(left, bottom, right, top, srid) pour construire l’enveloppe de sélection de l’étendue géographique, qui associée à l’opérateur && sur la géométrie de l’objet permet de trouver l’intersection entre l’enveloppe et la géométrie.

CREATE INDEX intersect_etabs_cul_geom_gist
  ON intersect_etabs
  USING gist
  (cul_geom);
SELECT * FROM intersect_etabs 
WHERE cul_geom && ST_MakeEnvelope(0.23036956787109372,44.9590891448628,0.3277873992919922,45.00419734261587, 4326)
AND distance < 1000;

La requête s’exécute en 11 ms, ce qui est compatible avec une utilisation en temps réel sur la carte.

Compilation des données par département

L’objectif est de créer une table, relativement longue à générer mais très rapide à interroger.

WITH   proximites(a) AS ( VALUES ('{100, 250, 500, 750, 1000, 1250, 1500}'::int[]) )
SELECT generate_subscripts(a, 1) AS idx, unnest(a) AS proximite
FROM   proximites;

Cette première requête permet de générer l’ensemble des proximités aux établissements proposées à l’utilisateur, elle peut être utilisée dans une requête plus complexe de création de table.

CREATE TABLE proximites AS (
WITH   proximites AS (
WITH generateur(a) AS ( VALUES ('{100, 250, 500, 750, 1000, 1250, 1500}'::int[]) )
SELECT generate_subscripts(a, 1) AS idx, unnest(a) AS valeur
FROM   generateur
)
SELECT p.valeur as proximite, count(distinct i.numero_uai) as intersections, i.code_dept 
FROM proximites p
LEFT JOIN intersect_etabs i
ON i.distance < p.valeur
GROUP BY p.valeur, i.code_dept
ORDER BY p.valeur, i.code_dept
)

L’évolution de la table pour d’autres valeurs est simple à réaliser…

INSERT INTO proximites (
SELECT '250'::text as proximite, count(distinct i.numero_uai) as intersections, i.code_dept 
FROM intersect_etabs i 
WHERE i.distance < 250 
GROUP BY i.code_dept
)

Vérifions que les résultats sont conformes aux attentes de réactivité.

select intersections, code_dept from proximites where proximite = '250';

Nous sommes bien sous les 10 ms.

Nous avons compilé les données et examiné les requêtes spatiales qui vont permettre l’interactivité avec l’utilisateur depuis la carte. Nous pouvons désormais utiliser la table des intersections entre établissements et cultures sur une étendue géographique donnée, et à niveau de détail moins élevé la table qui recense le nombre d’établissements en fonction de la proximité aux cultures pour chaque départements.

Voyons comment représenter dynamiquement ces informations sur la carte.

Représenter les résultats JSON de requêtes spatiales

Le principe est simple. Une carte est crée qui constitue la vue par défaut. Une interaction utilisateur va déclencher le changement de la vue pour s’adapter au contexte. Techniquement un écouteur d’événements est mis en place dans le code Javascript qui s’exécute côté client sur le navigateur Internet.

Au déclenchement d’un événement, un appel Javascript asynchrone interroge un script distant qui retourne l’information au format JSON. Le script distant a pour responsabilité de faire des requêtes spatiales sur la base de données et mettre en forme le contenu au format d’échange.

Le contenu retourné peut comporter des informations spatiales à représenter sous la librairie Javascript, dans ce cas le contenu d’échange sera du JSON, qui pourra inclure des objets GeoJSON. Nous utiliserons ce principe pour récupérer les points qui permettrons de localiser et représenter les établissements dans la vue détaillée, toutefois ce n’est pas obligatoire : en effet dans notre cas nous avons déjà interrogé le serveur cartographique depuis le service WFS afin de disposer des éléments de représentation des départements, pourrait nous suffire dans la vue générale de l’information de décompte des établissements par département. Le décompte pourrait très bien être affiché par rapport à la géométrie des départements déjà connue.

Script de proxy à la base de données PostgreSQL

Le script distant agit comme un proxy, il permet de masquer les informations de connexion à la base de données et isoler les traitements sur la base. Pour l’application le script sera développé en langage PHP, mais évidemment n’importe quel autre langage au travers d’un serveur Web peut faire l’affaire.

Afin de simplifier le prototype j’utiliserai un script par requête spatiale. Sur un projet d’envergure on peut évidemment préférer mettre en place un contrôleur frontal.

Le premier script va effectuer une requête qui permettra de représenter le nombre d’établissements à proximité de cultures pour chaque département lorsque le niveau d’agrandissement de la carte laisse apercevoir une étendue géographique relativement large, de l’ordre de dimension d’un quart de la France métropolitaine.

Le rendu sera le suivant

Représentation par cercles proportionnels

L’analyse par symboles proportionnels sert à représenter un indicateur quantitatif en valeurs absolues (nombres, quantités, surfaces…). Ici je ne peux pas afficher en temps réel les 60 000 établissements sur la carte de France, aussi j’utiliserai des cercles proportionnels pour donner une première indication d’un nombre d’établissements adjacents aux cultures pour la distance renseignée dans le formulaire de paramétrage de la carte, pour chaque département. Cette première représentation est minimaliste et peu utile – c’est plutôt le savoir faire qui m’intéresse ici-, elle ne prend pas en compte les autres paramètres afin de pouvoir représenter un minimum d’informations en temps réel.

Chaque valeur de l’indicateur est représentée par un symbole dont la surface est proportionnelle à la valeur représentée, notre indicateur sera le décompte d’établissements adjacents.

Ces symboles proportionnels sont ajoutés sur la carte par ordre décroissant de sorte que les symboles les plus petits soient au-dessus des plus gros.

Interroger POSTGIS en PHP et retourner un résultat JSON / GeoJSON

Le script est enregistré à la racine du répertoire web du serveur Apache, il se nomme action1.php

<?php
if (!isset($_GET['callback'])) {
  header('status: 400 Bad Request', true, 400);
  exit;
}

$conn = new PDO('pgsql:host=localhost;dbname=Pesticides', 'user', 'password');
    
$distance = isset($_GET['distance']) ? (int) $_GET['distance'] : 250;

$sth = $conn->prepare('
    select p.intersections, p.code_dept, d.nom_dept, 
    ST_AsGeoJSON(ST_Centroid(d.geom)) as centroid
    from proximites p
    inner join "Departement" d
    ON p.code_dept = d.code_dept 
    where proximite = :proximite  
    order by p.intersections desc'
);
$sth->execute(array(':proximite' => $distance));
$result = $sth->fetchAll(PDO::FETCH_OBJ);

header('content-type: application/javascript; charset=utf-8');
header("access-control-allow-origin: *");
echo filter_var($_GET['callback'], FILTER_SANITIZE_ENCODED), '(', json_encode($result), ');';

Que fait ce script ?

Premièrement il vérifie la présence dans les paramètres d’url d’un paramètre nommé callback. Ce paramètre est simplement le nom de fonction qui encapsule l’objet JSON retourné (format JSONP). J’ai déjà expliqué ce principe, vous pouvez vous référer aux parties 7 ou 8 de ce document.

Le script ouvre une connexion locale sur le serveur PostgreSQL hébergé sur la même machine, puis il exécute une requête préparée sur la connexion ouverte avec le paramètre de distance fourni afin de récupérer les informations qui serviront à la représentation. Les informations de requêtes sont récupérées sous la forme d’un objet PHP grâce au paramètre PDO::FETCH_OBJ qui sera intégralement converti au format JSON par la fonction PHP json_encode().

Finalement le script renvoi les entêtes de contenu javascript et celles d’autorisation d’accès depuis un hôte distant, puis affiche le contenu de retour JSON.

Si l’on s’attarde sur la requête spatiale, vous pouvez constater que les champs code département, nom du département, et décompte sont retournés tels quels en valeurs de chaînes de caractères ou entiers.

Pour la représentation je récupère une géométrie sous forme de POINT, le centre du polygone qui défini un département.

ST_AsGeoJSON(ST_Centroid(d.geom)) as centroid

ST_Centroid – Retourne le centre géométrique d’une entité géométrie, dans le cas du département il retourne un point aux coordonnées du centre de masse de la géométrie (barycentre 2D du polygone qui matérialise les contours du département)

Le point central est transformé en notation GeoJSON pour la commodité du format d’échange.

ST_AsGeoJSON — Retourne une géométrie en tant qu’élément GeoJSON. Notez que cette fonction peut prendre en argument un nombre maxdecimaldigits qui permet de préciser la précision décimale souhaitée, une précision plus faible à notamment pour intérêt d’alléger le volume de données transférées.

Afin de respecter l’ordre de représentation des cercles, la requête effectue un tri sur le décompte, les plus petits cercles seront les derniers représentés dans l’ordre du résultat de recherche.

Exploiter le résultat de requête spatiale POSTGIS depuis un appel Ajax (côté client Javascript)

Le script PHP sert de proxy, il effectue une requête sur la base de données et retourne un résultat au format JSONP où les données spatiales sont embarquées avec les autres éléments de données mais au format GeoJSON.

Côté client Javascript il suffit donc d’appeler le proxy et traiter le résultat lorsque l’interaction avec l’utilisateur requiert de rafraîchir les informations. Nous avons deux possibilités d’interactions : soit l’utilisateur agit sur les contrôles de navigation de la carte afin de changer le niveau d’agrandissement ou de se déplacer vers une autre étendue géographique, soit il agit sur le formulaire de paramétrage pour modifier les conditions de rendu (dans ce cas uniquement la distance de proximité).

Chaque événement va provoquer le rafraîchissement de la vue en cours, à cette fin on va utiliser une fonction qui permettra de réinitialiser les couches dynamiques de la carte.

var dynamicFeatures = new L.featureGroup([]).addTo(map);

Sous Leaflet nous pouvons déclarer chaque couche représentée dynamiquement sous un groupe qui permet de manipuler toutes les couches au travers d’un seul élément. Le groupe est initialisé dans le contexte global et ajouté à la carte.

function refreshView() {
    dynamicFeatures.clearLayers(); 
...
    if (map.getZoom() < 10)
        getDecompteDepartemental();
...
}

La fonction qui rafraîchit la vue en cours commence par supprimer toutes les couches du groupe. Ensuite en fonction du niveau d’agrandissement de la carte on va rechercher les informations souhaitées. L’affichage de cercles proportionnels au niveau départemental est déclenché sous un niveau de zoom à 10, au dessus de ce niveau on choisira un autre mode de représentation.

$("#settings-form").on("change submit", function(event) {
  event.preventDefault();
  refreshView();
});

JQuery permet de créer un écouteur d’événement sur le formulaire de paramètres d’identifiant #settings-form. Les événements change et submit, déclencheront le rafraîchissement des couches dynamiques lors d’un changement sur l’un des champs du formulaire ou lors de sa soumission.

map.on('load zoomend moveend', function() {
    refreshView();
});

L’API Leaflet permet de définir directement un écouteur d’événements sur la carte. Ici on déclenche le rafraîchissement de la vue lors du chargement, lors du changement de niveau d’agrandissement, et lors d’une opération de glisser déposé.

Ces premiers éléments en place, on peut implémenter la fonction qui va représenter les cercles proportionnels.

function getDecompteDepartemental() {

    var distance = $("#distance").val();

    var query = host + '/action1.php?callback=getJson&distance=' 
            + encodeURIComponent(distance);

    $.ajax({
        url: query,
        dataType: 'jsonp',
        jsonpCallback: 'getJson',
        success: function (response) {
...
        }
    });
}

Le mécanisme est toujours le même (voir les documents antérieurs pour adaptation à la librairie OpenLayers 3), premièrement je construis l’url vers le script de proxy, cette url accepte deux paramètres, le nom de la fonction de rappel exploitée par le format JSONP et la distance récupérée en JQuery depuis le champ de sélection du formulaire qui possède l’identifiant #distance.

Un appel asynchrone permet d’obtenir la réponse du script de proxy, et en cas de succès on peut procéder à l’exploitation des résultats dans le format de réponse pour représenter les couches de données sur la carte.

En cas de succès, nous avons besoin de la valeur minimale (ou maximale) parmi les valeurs de décompte, ce minimum est exploité pour le calcul du rayon de chaque cercle.

...
            var values = [];
            for (var key in response)
                values.push(parseInt(response[key].intersections));

            var min = Math.min.apply(null, values);
...

Pour représenter chaque cercle il faut parcourir le résultat de requête, et pour chaque ligne de résultat créer un cercle proportionnel au décompte ayant pour centre le point (la géométrie) retournée au format GeoJSON qui correspond au centre de masse du polygone départemental. Ce cercle est une couche vectorielle qui est ajoutée au groupe d’éléments dynamiques pour être représentée sur la carte.

...
            for (var key in response) {
                var obj = response[key];
                var geometry = JSON.parse(obj.centroid);
                var circle = L.circleMarker([geometry.coordinates[1], geometry.coordinates[0]], {
                    color: 'white',
                    fillColor: 'Orange',
                    fillOpacity: 0.25,
                    opacity: 1
                });
                circle.setRadius(getRadius(parseInt(obj.intersections), min));
                circle.bindPopup(obj.nom_dept + " : " 
                        + obj.intersections + " établissement(s) à proximité" );
                
                dynamicFeatures.addLayer(circle);
            }
...

Pour chaque élément de réponse, on assigne l’élément à un objet nommé obj. Cet objet contient les propriétés telles que définies dans notre requête SQL, c’est à dire : intersections, code_dept, nom_dept, centroid. Les intersections sont un nombre, le décompte, le code département et le nom de département sont des chaînes de caractères, et le centroid un point au format GeoJSON, le format GeoJSON est parcouru pour être transformé en objet javascript à l’aide de la fonction Javascript JSON.parse(). Depuis ce nouvel objet on peut récupérer les coordonnées de latitude et longitude du point pour la représentation sous forme de cercle.

Le rayon du cercle est défini par rapport au décompte et au décompte minimal puis un popup est rattaché au cercle, il s’affichera au clic sur le cercle. Le contenu du popup est au format html, il contient des informations relatives au départements récupérées depuis le résultat de requête.

Dernier point crucial : le cercle est ajouté au groupe que l’on a créé en amont et qui est déjà inclus dans la carte, ce qui provoque l’affichage de l’élément sur la carte.

Concernant le calcul du rayon pour la représentation du cercle je vous communique les fonctions sans entrer dans les détails. Je ne suis pas entièrement satisfait du résultat lors de changements d’échelles de la carte et je vous invite à proposer une solution améliorée.

...
// calcule le rayon du cercle proportionnel
// Retourne le rayon du plus petit cercle

function getMinRadius() {
    var bounds = map.getPixelBounds();
    return Math.floor((bounds.max.x - bounds.min.x) * 0.005);
//    var size = map.getSize();
//    return Math.floor(size.x * 0.005);
}

// Retourne le rayon du cercle
// n est la valeur
// min est la valeur minimale

function getRadius(n, min) {
    return Math.floor(getMinRadius() * Math.sqrt(n / min));
}
...

Sur le même principe nous pouvons récupérer et représenter des géométries plus complexes, c’est exactement ce dont on a besoin à un niveau d’agrandissement élevé pour représenter un établissement.

Un établissement est représenté par un marqueur de position, une icône, la distance de proximité à l’établissement est matérialisée par un cercle opaque autour de ce point, et l’intersection entre les cultures et ce disque est mise en évidence par une couche opaque superposée.

Le script de proxy, nommé action2.php est très similaire au script précédent, seul le traitement des paramètres d’url et la requête à la base changent.

La requête SQL est un cas très interessant, riche en enseignements, et c’est le coeur du projet aussi nous allons la passer en revue. Je vais ignorer le traitement en amont des paramètres pour être plus succint.

...
$bbox = explode(',', $_GET['bbox']);

$sql = '
WITH resultats AS (
SELECT 
e.numero_uai, e.appellatio, e.adresse_ua, e.code_post, e.localite_a, e.nature_uai,
ST_AsGeoJSON(ST_Transform((e.geom), 4326), 6) AS etablissement,
floor(sum(ST_Area(CAST(cul_geom As geography)))) * POWER(10, -4) as surface_totale_cultures,
floor(sum(ST_Area(CAST(ST_Intersection(ST_Buffer(e.geom::geography, :distance)::geometry, cul_geom) As geography)))) * POWER(10, -4) as surface_culture_proximite,
ST_AsGeoJSON(ST_Transform((ST_Union(cul_geom)), 4326), 6) AS cultures,
ST_AsGeoJSON(ST_Transform((ST_Union(ST_Intersection(ST_Buffer(e.geom::geography, :distance)::geometry, cul_geom))), 4326), 6) AS surfaces_proximite,
count(i.num_ilot) as nombre_parcelles,
count(distinct c.cult_maj) as types_culture 
FROM intersect_etabs i 
INNER JOIN "Etablissement" e 
ON i.numero_uai = e.numero_uai 
INNER JOIN master_cultures c
ON i.num_ilot = c.num_ilot  
WHERE cul_geom && ST_MakeEnvelope(:xmin, :ymin, :xmax, :ymax, 4326)
AND distance < :distance 
AND c.cult_maj not in (18, 19)  -- Elimine les prairies
AND e.nature_uai < 800          -- Elimine les etablissements administratifs
AND e.etat_etabl = 1            -- Elimine les etablissements fermes
' . $whereClause . ' 
group by e.numero_uai  
order by e.numero_uai
)
SELECT *
FROM resultats
WHERE surface_culture_proximite > :surface
' . $aggregateWhereClause . ' 
ORDER BY types_culture DESC, surface_culture_proximite DESC, nombre_parcelles DESC
';

$sth = $conn->prepare($sql);
$sth->execute(array(
    ':distance' => $distance,
    ':surface' => ($surface * pow(10, -4)),
    ':xmin' => $bbox[0],
    ':ymin' => $bbox[1],
    ':xmax' => $bbox[2],
    ':ymax' => $bbox[3],
));
...

Cette requête est assez monolithique à première vue, nous pouvons la découper pour appréhender le fonctionnement.

...
WITH resultats AS (
SELECT 
...
ST_AsGeoJSON(ST_Transform((ST_Union(ST_Intersection(ST_Buffer(e.geom::geography, :distance)::geometry, cul_geom))), 4326), 6) AS surfaces_proximite,
...
group by e.numero_uai
)
SELECT *
FROM resultats
WHERE surface_culture_proximite > :surface
...

L’application permet de sélectionner une surface dans le formulaire de paramètres, l’un des objectifs est donc de déterminer la surface totale impactée dans le rayon de proximité autour de l’établissement toutes parcelles confondues pour appliquer un filtre par rapport au paramètre.

Pour calculer la surface il est nécessaire d’agréger les données de parcelles pour chaque établissement. L’opération d’agrégat est réalisée par l’instruction SQL GROUP BY, le résultat d’une requête de type SELECT va retourner les résultats agrégés, une autre requête est alors exécutée sur le résultat de la précédente pour appliquer les paramètres de filtre.

L’imbrication de requêtes est réalisée par les instructions WITH, AS de PostgreSQL, les clauses de conditions stipulent de retourner les résultats agrégés pour lesquels la surface est supérieure à la surface paramétrée (c’est également au niveau de ces clauses SQL que l’on insère si besoin la restriction d’adjacence à plusieurs cultures)

Comment aggréger des objets géométriques ?

Dans notre cas on définit en premier une zone tampon autour du point qui matérialise l’établissement avec la fonction ST_Buffer, que nous avons déjà employé précédemment.

ST_Buffer(e.geom::geography, :distance)::geometry

ST_Intersection — Retourne une géométrie qui représente la portion commune de deux géométries, ici elle est utilisée sur la géométrie de la zone tampon et celle de la parcelle pour ne conserver que la surface en commun.

ST_Union — Retourne une géométrie qui représente le jeu de points d’union ensembliste des géométries, elle va réunifier toutes les intersections de la zone tampon avec les cultures à proximité de l’établissement.

ST_Transform – Retourne une nouvelle géométrie avec ses coordonnées transformées pour le SRID référencé par le paramètre entier, dans notre cas le SRID 4326 permet de travailler selon nos attentes depuis la librairie Javascript.

Une fois ces informations comprises, le reste de la requête est presque trivial. Expliquons les deux derniers points de difficulté.

SELECT ... floor(sum(ST_Area(CAST(cul_geom As geography)))) * POWER(10, -4) as surface_totale_cultures,

L’information retournée comporte une indication de la surface totale des parcelles à partir du moment où la parcelle répond aux paramètres de recherche. La surface est calculée depuis un objet géographie.

L’opération de conversion de type permet de transformer la géométrie de la parcelle en géographie, la fonction ST_Area va retourner l’aire de l’objet, tandis que la fonction SUM va additionner les aires.

Au final la somme des aires est arrondie à l’entier le plus bas et multipliée par la puissance de 10 à l’exposant -4 afin de retourner une quantité exprimée en hectares.

La fonction ST_MakeEnvelope (voir plus haut) restreint la requête aux résultats sur l’étendue géographique passée en paramètres depuis le client Javascript.

    var query = host + '/action2.php?callback=getJson&' + $("#settings-form").serialize()
        + '&bbox=' + bounds.toBBoxString();

Les paramètres sont construits depuis la fonction d’appel, et le résultat de requête est traité à nouveau en cas de succès.

L’Education Nationale a mis à disposition un répertoire des établissements scolaires, l’application permet de renseigner le numéro d’établissement dans le formulaire de recherche.

Pour clôre ce chapitre, voici côté client la fonction de représentation des établissements, suivie d’une copie d’écran du rendu sur la carte.

var premierDegre = L.MakiMarkers.icon({icon: "school", color: "#b0b", size: "m"});
var secondDegre = L.MakiMarkers.icon({icon: "school", color: "#E2492F", size: "m"});

function getEtablissements(bounds) {

    var distance = $("#distance").val();

    var query = host + '/action2.php?callback=getJson&' + $("#settings-form").serialize()
        + '&bbox=' + bounds.toBBoxString();

    $.ajax({
        url: query,
        dataType: 'jsonp',
        jsonpCallback: 'getJson',
        success: function (response) {

            for (var key in response) {
                var obj = response[key];

                // Représente l'établissement par un marqueur de position
                
                var etablissement = JSON.parse(obj.etablissement);
                var coordinates = etablissement.coordinates[0];
                var icon = (obj.nature_uai < 300) ? premierDegre : secondDegre;
                var link = "<a href='http://www.education.gouv.fr/bce/index.php?simple_public=" 
                        + obj.numero_uai + "'>" + obj.appellatio + "</a>"
                
                var marker = L.marker([coordinates[1], coordinates[0]], {icon: icon})
                    .bindPopup(
                        link + "<br /> " 
                        + (obj.adresse_ua ? obj.adresse_ua + ', ' : '') 
                        + obj.code_post + ' ' + obj.localite_a + "<br /> " 
                        + 'Surface à proximité : ' + obj.surface_culture_proximite  + " ha<br /> " 
                        + 'Surface totale parcelles : ' + obj.surface_totale_cultures + " ha<br />"
                        + obj.nombre_parcelles + ' parcelle(s)' + "<br />"
                        + obj.types_culture + ' type(s) de cultures' + "<br />"
                        );

                // La zone tampon autour de l'établissement est représentée par un cercle

                var buffer = L.circle([coordinates[1], coordinates[0]], distance, {
                    color: 'red',
                    fillColor: '#f03',
                    fillOpacity: 0.25,
                    opacity: 0.65,
                    weight: 1
                });

                var parcelles = L.geoJson(JSON.parse(obj.cultures), {
                    style: {
                        "color": "#ff7800",
                        "weight": 1,
                        "opacity": 0.65
                    }
                });
                
                var intersections = L.geoJson(JSON.parse(obj.surfaces_proximite), {
                    style: {
                        "color": "red",
                        "weight": 1,
                        "opacity": 0.95
                    }
                });
                
                dynamicFeatures.addLayer(parcelles);
                dynamicFeatures.addLayer(intersections);
                dynamicFeatures.addLayer(buffer);
                dynamicFeatures.addLayer(marker);   
                
                if (map.getZoom() > 15)
                    getJsonLayerParcelles(bounds);
            }
        }
    });
}

Fonctionnalités annexes spécifiques Leaflet

Pour perfectionner un peu la carte, j’ai ajouté un ensemble de fonctionnalités.

Lorsque l’utilisateur arrive sur la carte, j’ai trouvé utile de positionner la carte sur sa localisation.

Cela peut se faire nativement sous Leaflet.

map.locate({setView: true, maxZoom: 15});

La barre latérale est par défaut repliée, ce qui n’est pas intuitif pour l’utilisateur mais si l’on déplie la barre par défaut il n’est pas intuitif de la replier non plus.

En attendant un boutton de fermeture sur cette extension, j’ouvre la barre latérale au chargement et je la referme après quelques secondes. Les opérations sur la barre peuvent s’effectuer depuis les identifiants html des onglets.

setTimeout(function () {
    sidebar.open('home');
}, 1000);
setTimeout(function () {
    sidebar.close('home');
}, 4000);

Finalement, afin de donner à l’utilisateur la possibilité de passer en vue aérienne je fais appel aux serveurs WMS de l’IGN.

Conclusion

Au terme de cette série de documents sur la réalisation d’une application cartographique j’ai détaillé comment réaliser une carte dynamique depuis les données et leur mise à disposition, jusqu’à la représentation dynamique des données.

Techniquement je n’ai pas abordé par manque de temps la représentation d’une carte par chloroplèthe, ce sera l’objet d’un futur document, cette fois ci sous OpenLayers 3.

J’espère que cette application trouvera une utilité publique ou qu’à défaut ce travail motivera ce type de représentations.

Requête à un serveur cartographique sous client web OpenLayers3

Etude du code prototype projet OpenLayers 3

Nous avons généré automatiquement dans QGIS un prototype qui permet de visualiser une carte au travers d’un client web, explorons le code…

Le meilleur moyen d’aborder OpenLayers3 est de passer par les exemples et d’explorer la librairie. (Téléchargez la dernière version d’OpenLayers, vous trouverez les codes sources des exemples et la librairie en plus de la version des codes sources au complet)

Concept

La composante de base d’OpenLayers3 est la carte (ol.Map). Cette carte est rendue dans un conteneur cible, en pratique dans un élément div de la page Web qui contient la carte.

Les propriétés de la carte peuvent être configurées au moment de sa construction, ou ultérieurement par des méthodes spécifiques.

Le conteneur cible qui a été généré pour nous est un élément div d’identifiant map (il comporte des sous-divisions pour permettre un affichage en fenêtre modale dont on pourrait se passer pour simplifier l’exemple…).

        <div id="map">
            <div id="popup" class="ol-popup">
                <a href="#" id="popup-closer" class="ol-popup-closer"></a>
                <div id="popup-content"></div>
            </div>  
        </div>

Ce conteneur possède un style d’affichage paramétré en entête du code html, le style est paramétré ici pour un affichage à 100% de la taille de la fenêtre du navigateur.

        <style>
            html, body, #map {
                height: 100%;
                width: 100%;
                overflow: hidden;
            }
         ...
        </style>

Notez que la librairie OpenLayers et ses extensions possèdent leurs propres fichiers de style qui sont chargés en ressources en entête de fichier html.

        <link rel="stylesheet" href="./resources/ol.css">
        <link rel="stylesheet" href="./resources/boundless.css">

ol.Map ne gère pas lui même le niveau de zoom et la projection de la carte, ce sont des propriétés et méthodes d’une instance de vue, ol.View, qui est initialisée dans la carte.

Pour obtenir les données à représenter pour une couche, OpenLayers utilise des sous-classes ol.source.Source. Ces sources permettent d’alimenter la carte au travers de serveurs de tuiles, de serveurs WMS ou WMTS, de données vectorielles dans des formats comme GeoJSON ou KML. Voyons comment sont configurées les sources par défaut dans notre prototype.

Chargement des ressources javascript

Premièrement le prototype charge la librairie OpenLayers 3

<script src="./resources/ol.js"></script>

Puis une il fait appel à une extension qui implémente un composant qui permet de permuter la visibilité des couches…

<script src="./resources/boundless.js"></script>

Le script charge ensuite les données de chaque couche, ces données ont été transformées au format Geojson par l’extension installée sous QGIS, ce sont ces données qui sont les sources de notre prototype par défaut.

Si l’on considère la couche qui comporte les informations départementales, le script va faire appel à deux ressources, la première comporte les données de la couche au format Géojson

<script src="layers/Departement.js"></script>

qui a pour contenu

var geojson_Departement = {
    "type":"FeatureCollection"
    },
    "crs":{
    "type":"name",
            "properties":{
            "name":"urn:ogc:def:crs:EPSG::3857"}
    }, "features":[
    {"type":"Feature", "properties":{}, "geometry":{"type":"MultiPolygon", "coordinates":
            [[[[649129.155, 5770492.803], [648115.547, 5769160.517],
                    [648898.518, 5767096.316], [648562.967, 5764862.309],
                    [647339.990, 5760692.349], [646490.730, 5758044.713], 
                            ...

GeoJson est un format qui permet d’encoder des structures de données géographiques, nous ne nous attarderons pas sur le format, notre objectif est de remplacer ces données statiques par des données fournies dynamiquement par notre serveur cartographique.

La seconde ressource est un script Javascript qui configure le style de représentation de la couche.

<script src="styles/Departement_style.js"></script>

qui a pour contenu (relativement obscure à cause du code environnant)

var styleCache_Departement = {}
var style_Departement = function (feature, resolution) {
    var value = ""
    var style = [new ol.style.Style({
            stroke: new ol.style.Stroke({color: "rgba(0,255,0,1.0)", lineDash: null, width: 1})
        })
    ];
    var labelText = "";
    var key = value + "_" + labelText

    if (!styleCache_Departement[key]) {
        var text = new ol.style.Text({
            font: '8.25px Calibri,sans-serif',
            text: labelText,
            fill: new ol.style.Fill({
                color: "rgba(0, 0, 0, 255)"
            }),
        });
        styleCache_Departement[key] = new ol.style.Style({"text": text});
    }
    var allStyles = [styleCache_Departement[key]];
    allStyles.push.apply(allStyles, style);
    return allStyles;
};

Finalement le programme charge une dernière ressource qui permet d’initialiser chaque couche depuis les ressources chargées précédemment.

<script src="./layers/layers.js" type="text/javascript"></script>

C’est dans ce script que se passe le plus gros travail d’initialisation.

var baseLayer = new ol.layer.Tile({title: 'OSM', source: new ol.source.OSM()});
...
var lyr_Departement = new ol.layer.Vector({
                source: new ol.source.GeoJSON({object: geojson_Departement}), 
                style: style_Departement,
                title: "Departement"
            });
...
lyr_Departement.setVisible(true);
...
var layersList = [baseLayer,lyr_cultures,lyr_Departement,lyr_DepartementWFS,lyr_etabstamponselec,lyr_etabssel,lyr_etabparcelintersm];

Initialisation des couches

Dans le script layers.js, le script va construire la liste des couches à afficher. Chaque couche est crée l’une à la suite de l’autre avec une source de données, un style et d’autres paramètres. Puis la visibilité de chaque couche est positionnée à la valeur vraie (c’est toutefois la valeur par défaut lors de l’initialisation). La liste des couches est le tableau résultant.

var baseLayer = new ol.layer.Tile({title: 'OSM', source: new ol.source.OSM()});

Ici on construit un objet ol.layer.Tile qui va permettre d’interroger un fournisseur de tuiles (images pré-rendues). L’argument title n’est pas dans les options de l’objet, mais comme cet objet implémente la classe abstraite ol.Object l’argument permet de renseigner un attribut qui possédera des accesseurs (qui pourront être exploités par le composant qui permet de permuter la visibilité des couches)

La source de données ol.source.OSM est pré-configurée dans la librairie et permet d’interroger les serveurs de tuiles d’OpenStreetMap.

var lyr_Departement = new ol.layer.Vector({
                source: new ol.source.GeoJSON({object: geojson_Departement}), 
                style: style_Departement,
                title: "Departement"
            });

Ces instructions permettent de créer une couche de données vecteur, rendue côté client. La source de la couche est au format GeoJSON, elle accepte un objet GeoJSON, geojson_Departement, qui a été construit lors de l’export du projet QGIS et chargé en amont dans les ressources Javascript.

Le style de rendu de la couche vectorielle ol.layer.Vector est également fournit en option lors de la construction, il permet de passer l’objet ol.style.Style également chargé en amont dans les ressources javascript.

lyr_Departement.setVisible(true);

L’objet de couche est déjà défini, le script utilise explicitement un accesseur pour conditionner la visibilité.

var layersList = [baseLayer, ..., lyr_Departement, ...];

La liste des couches est finalement renseignée dans une variable layersList a contexte global.

Initialisation et rendu de la carte

Le script vient d’obtenir les données à représenter pour chaque couche il reste à initialiser l’objet carte mais avant cela il faut encore expliquer un morceau de code généré pour nous.

            var container = document.getElementById('popup');
...
            var overlayPopup = new ol.Overlay({
                element: container
            });

Sous OpenLayers3 il existe une classe de widgets qui s’affiche en superposition, ol.Overlay.

Le code créé une instance de widget qui sera superposée à l’élément du DOM récupéré dans la variable container (qui possède l’identifiant popup)

L’extension disponible sous QGIS génère beaucoup de code superflus pour un usage basique. La gestion de popups et overlays ne m’est d’aucune utilité et sera supprimée du prototype ainsi que toute la gestion d’événements à la souris.
            var map = new ol.Map({
                controls: ol.control.defaults().extend([
                    new ol.control.ScaleLine({}), new Boundless.LayersControl({groups: {default: {title: "Layers"}}})
                ]),
                target: document.getElementById('map'),
                renderer: 'canvas',
                overlays: [overlayPopup],
                layers: layersList,
                view: new ol.View2D({
                    maxZoom: 28, minZoom: 1
                })
            });

La construction de l’objet carte ol.Map est le cœur de la librairie. Elle requiert de renseigner une vue, ajouter une ou plusieurs couches, et préciser un élément conteneur cible.

Les options générées dans l’exemple sont les suivantes :

  • controls : Collection de contrôles. Un contrôle ol.control.Control est un widget représenté en position fixe à l’écran. Un contrôle peut être seulement informatif ou bien peut permettre d’interagir avec l’utilisateur. Une collection de contrôles par défaut est incluse aux cartes, ol.control.defaults (Elle comporte les widgets de Zoom, Rotation et Attributions). Ici le code ajoute deux contrôles à la collection par défaut, en premier l’échelle et en second le widget qui permet de sélectionner les couches à afficher ou masquer. Le rendu des contrôles est paramétrable par application de styles css
  • target : Elément du DOM où la carte sera incluse
  • renderer : Moteur de rendu, seul le canvas a les capacités de rendu vectoriel
  • overlays : Collection de widgets en superposition (contrairement aux contrôles la position des widget n’est pas fixe, elle dépend de la carte)
  • layers : Collection de couches
  • view : Vue associée à la carte.

La vue est un objet qui représente une vue 2D simple de la carte, elle permet de spécifier le centre, la résolution et la rotation de la carte. La façon la plus simple de définir une vue est de définir un point central et un niveau de zoom.

Dans notre exemple la vue est initialisée uniquement avec un niveau de zoom minimal et maximal.

Un objet ol.View possède une projection. La projection détermine le système de coordonnées du centre et ses unités déterminent les unités de résolution (projection d’unités par pixels). La projection par défaut est sphérique Mercator (EPSG: 3857), système de projection utilisé entre autre par OSM et Google Maps, et comme vous pouvez constater elle n’est pas renseignée dans notre cas et correspond bien à la projection utilisée dans les fichiers de données Geojson.

Et ensuite ?

map.getView().fitExtent([-225069.813761, 5267288.926421, 186886.432518, 5746435.175340], map.getSize());

La vue associée à l’objet carte est récupérée. La méthode fitExtent() est au stade expérimental (vous devez décochez la case Stable Only dans l’API pour faire apparaître la documentation !), elle fait s’adapter la vue à l’étendue géographique et à la taille données. La taille est en pixels, elle est déterminée ici par la taille de la carte. L’étendue est un tableau de nombres qui représentent une étendue géographique, il s’agit de [minx, miny, maxx, maxy]. Ces valeurs sont les valeurs récupérées sous QGIS lors de l’export du projet, il est bien entendu possible de les modifier pour s’adapter à une autre étendue par défaut. (Il suffit sous QGIS de copier-coller les valeurs pour la vue qui convient)

Comme les données ne concernent que la Gironde, j’ai basculé le SCR du projet à EPSG:3857 sous QGIS et zoomé la sélection sur la Gironde à partir des attributs de la couche des départements. L’emprise est disponible dans la barre de statut de QGIS en bas à gauche après permutation de l’affichage de position du curseur.

Ce qui donne donc

map.getView().fitExtent([-174152,5464320,85720,5738798], map.getSize());

Le reste du code n’a pas d’incidence sur le rendu de carte, il permet de gérer l’interaction souris avec l’utilisateur. (Et à vrai dire ne semble pas fonctionner)

Nous avons donc expliqué le code et vu comment générer une carte à partir de sources de données vectorielles sous forme de fichiers. Comment adapter le code pour interroger un serveur de données ?

Adaptation du code pour une source de données par serveur cartographique

Nous avons a disposition pour le projet deux services qui tournent sous un serveur cartographique QGIS Server.

Les capacités WFS et WMS des services peuvent être interrogées par les url suivantes

http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities
http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities

C’est ce dernier service que l’on va exploiter en premier.

Appel à un service WMS QGIS Server sous OpenLayers3

Le nom de la couche des départements nous intéresse particulièrement, on peut le récupérer depuis le fichier xml.

<Layer queryable="1">
<Name>Departement</Name>
<Title>Departement</Title>
<CRS>EPSG:4326</CRS>
<EX_GeographicBoundingBox>
<westBoundLongitude>-5.21251</westBoundLongitude>
<eastBoundLongitude>9.63332</eastBoundLongitude>
<southBoundLatitude>41.3141</southBoundLatitude>
<northBoundLatitude>51.1376</northBoundLatitude>
</EX_GeographicBoundingBox>
<BoundingBox CRS="EPSG:4326" maxx="51.1376" minx="41.3141" maxy="9.63332" miny="-5.21251"/>
<Style>
<Name>default</Name>
<Title>default</Title>
<LegendURL>
<Format>image/png</Format>
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple" xlink:href="http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&LAYER=Departement&FORMAT=image/png&STYLE=default&SLD_VERSION=1.1.0"/>
</LegendURL>
</Style>
</Layer>

Le parcours des sources de données ol.source dans l’API OpenLayers3 nous renseigne sur les possibilités de la librairie.

Il apparaît possible d’utiliser un service WMS pour fournir les images pour une couche donnée, le type est ol.source.ImageWMS.

Il suffit de remplacer la source locale pour la couche des départements.

//var lyr_Departement = new ol.layer.Vector({
//                source: new ol.source.GeoJSON({object: geojson_Departement}), 
//                style: style_Departement,
//                title: "Departement"
//            });

var lyr_Departement = new ol.layer.Image({
                source: new ol.source.ImageWMS({
                               url: "http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi",
                               params: {
                                   'LAYERS': 'Departement'
                               },
                            }),
                style: style_Departement,
                title: "Departement"
            });

La source ol.source.ImageWMS requiert l’url du service et un tableau des paramètres de la requête WMS en options, dont seul le paramètre layers est obligatoire. (Les autres paramètres sont renseignés dynamiquement par la librairie)

Il est possible de faire la même manipulation pour les autres couches en modifiant le nom de la couche dans l’option layers.

Les requêtes vers le serveur WMS peuvent être contrôlées sous les outils de développement de votre navigateur Web préféré afin d’étudier la requête générée et le temps d’exécution. A titre d’exemple une requête effectuée pour récupérer les parcelles de culture.

http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&LAYERS=cultures_33&CRS=EPSG%3A3857&STYLES=&WIDTH=2880&HEIGHT=1529&BBOX=-33130.75605296711%2C5560982.287734281%2C-5613.425870303659%2C5575591.314765286

Notez également que depuis ce type d’appel les paramètres d’affichage en résolution minimale et maximale sont respectés.

var lyr_cultures = new ol.layer.Image({
                source: new ol.source.ImageWMS({
                               url: "http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi",
                               params: {
                                   'LAYERS': 'cultures_33'
                               },
                            }),
                minResolution:-4.65661009752e-10,
                maxResolution:55785.0,
                style: style_cultures,
                title: "cultures_33"
            });

Dans l’exemple précédent les cultures ne s’affichent que dans la résolution souhaitée par contre les requêtes sont effectuées sur le serveur quelle que soit la résolution dans la vue représentée. Hors limites de représentation elles retournent une image vide (par défaut un png transparent).

Afin de limiter le nombre de requêtes au serveur il peut être intéressant de demander plusieurs couches dans une même requête, par exemple on peut regrouper les trois couches qui servent à représenter les établissements et leur proximité aux cultures.

var lyr_etabs = new ol.layer.Image({
        source: new ol.source.ImageWMS({
            url: "http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi",
            params: {
                'LAYERS': 'etabs_tampon_selec,etabs_sel,etab_parcel_inters_50m'
            },
        }),
        title: "Etablissements"
    });

En passant par le service WMS le serveur cartographique retourne une image, de ce fait on perd les possibilités offertes par la couche vectorielle Geojson. L’avantage d’une image est souvent sa légèreté sur le réseau par rapport à un fichier de données, mais le format vectoriel nous permet d’interagir en javascript avec les objets, on peut par exemple sélectionner un objet et modifier son style, supprimer ou ajouter un objet.

Interagir avec les objets peut permettre de créer des cartes plus interactives, en créant des composants de formulaire en widget par exemple on peut imaginer de représenter dynamiquement une carte choroplèthe ou des symboles proportionnels en fonction d’un paramètre sélectionné et d’une requête asynchrone qui va chercher les valeurs d’indicateurs à représenter.

Appel à un service WFS QGIS Server sous OpenLayers3

La librairie OpenLayers 3 a la capacité de charger des couches vectorielles depuis une méthode ol.layer.Vector() comme nous l’avons vu précédemment. A plus bas niveau elle permet d’utiliser la méthode ol.source.ServerVector(), toutefois encore en état expérimental, qui va permettre d’alimenter une source dans un format supporté depuis un serveur distant.

Il nous suffit en théorie d’utiliser le format ol.format.GeoJSON() avec notre serveur QGIS Server depuis une requête asynchrone côté client web.

En pratique, si l’on effectue une requête sur notre serveur afin de récupérer des données JSON depuis une page html côté client (en Ajax donc), le navigateur va interdire l’opération si le serveur n’autorise pas explicitement les accès en origine croisée par l’ajout d’une entête de contrôle d’accès.

Notre problème est double. Premièrement QGIS Server possède les capacités pour servir du GeoJSON, qui est simplement du JSON avec une convention, par contre il ne propose pas le format de sortie JSONP qui encapsulerait simplement le GeoJSON dans une fonction Javascript. Il nous resterait comme option de configurer le serveur pour accepter les requêtes d’origine croisées (CORS), cependant le serveur QGIS fonctionne en mode CGI et Apache ne peut pas à ma connaissance ajouter une entête d’autorisation sur la sortie produite par QGIS Server.

La solution lorsque l’on ne peut ni modifier le format de sortie ni configurer le serveur pour ajouter des autorisations est d’utiliser un proxy. Très simplement un script sera hébergé sur le même serveur, il acceptera en paramètres un nom de fonction qui servira à l’encapsulation javascript du format JSON, et l’url qu’il sera en mesure d’interroger depuis le même domaine.

J’ai créé un simple script en langage PHP qui servira de proxy. Vous pourrez probablement trouver des solutions plus complètes…

<?php
if(!isset($_GET['callback']) || !isset($_GET['url'])) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
extract(parse_url($_GET['url']));       

if (! ( in_array($host, array($_SERVER['HTTP_HOST'], $_SERVER['SERVER_ADDR']) )
      || ( basename($path) != 'qgis_mapserv.fcgi' ) )) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
if (!$content = file_get_contents(filter_var($_GET['url'], FILTER_SANITIZE_URL))) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
header('content-type: application/javascript; charset=utf-8');
header("access-control-allow-origin: *");
echo filter_var($_GET['callback'], FILTER_SANITIZE_ENCODED), '(', $content, ');';

Ce script nommé proxy.php est placé à la racine du répertoire web. Il doit être appelé avec deux paramètres, callback et url.

Le paramètre url doit être encodé…

http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=...

Le script effectue quelques contrôles, premièrement il n’aboutit pas si les paramètres ne sont pas renseignés. Il vérifie ensuite que l’url à interroger est sur le même domaine que le serveur, puis vérifie que l’on cherche bien à interroger un script CGI qui correspond à QGIS Server. Le script filtre ensuite les paramètres et exécute la requête. Le contenu de la requête est retourné encapsulé dans la fonction de rappel Javascript avec les bons entêtes : premièrement l’entête d’application javascript (et non pas l’entête d’application JSON), puis l’entête de contrôle d’accès qui autorise le proxy à être interrogé depuis un autre domaine.

Une fois ce problème réglé on peut passer au code.

            var DepartementWFSURL = 'http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?' + 
                    'SERVICE=WFS&REQUEST=GetFeature&' + 
                    'VERSION=1.1.0&TYPENAME=Departement&' + 
                    'SRSNAME=EPSG:3857&outputFormat=GeoJSON';
            
            var proxy = 'http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=';

Construit l’url d’appel à notre proxy en précisant la fonction de rappel et l’url d’origine du service.

            var deptsSource = new ol.source.ServerVector({
                format: new ol.format.GeoJSON(),
                loader: function(extent, resolution, projection) {
                    var url = DepartementWFSURL +
                        '&bbox=' + extent.join(',');

                    $.ajax({
                        url: proxy + encodeURIComponent(url),
                        dataType: 'jsonp',
                        jsonpCallback: 'getDepartementJson',
                    });
                },
                strategy: function() {
                    return [ [-5.21251302, 41.31412406, 9.63331895, 51.13763146] ];
                },
                projection: 'EPSG:3857'
            });

La source récupère les données WFS en GeoJSON en utilisant la technique JSONP.

Remarquez l’option strategy. OpenLayers 3 possède plusieurs types de stratégies ol.loadingstrategy pour le chargement des données. Il peut charger les données par tuiles en multipliant les appels au serveur, tout charger d’un coup, ou charger suivant les coordonnées de la boîte englobante (BBOX). Ici les coordonnées sont fournies sur l’étendue géographique complète [minx, miny, maxx, maxy].

L’option loader correspond à la fonction qui effectue le chargement, elle se voit passer l’étendue et la projection que l’on a paramétrées. Avec la stratégie de tuiles elle serait appelée de multiples fois. C’est que l’appel javascript asynchrone va récupérer les objets de la couche vectorielle depuis le service WFS

L’appel Ajax accepte ces paramètres :

  • url : url vers notre proxy qui interrogera le serveur WFS
  • dataType : notre proxy retourne du javascript au format JSONP, on doit préciser le type de données car les entêtes retournées sont application javascript
  • jsonpCallback : le nom de la fonction de rappel dans le fichier JSONP retournée par le proxy
            var getDepartementJson = function(response) {
                deptsSource.addFeatures(deptsSource.readFeatures(response));
            };

En cas de réussite la fonction anonyme de rappel est exécutée avec la réponse en paramètre, notre GeoJSON… la réponse est parcourue et ajoutée à la source vectorielle.

            var lyr_Departement = new ol.layer.Vector({
                source: deptsSource,
                title: 'Departement',
                style: style_Departement,
            });

Il ne reste plus qu’à exploiter la source vectorielle que nous venons de créer comme nous avons exploité les sources GeoJSON.

Nous avons fait le tour des codes sources générés sous une extension QGIS pour la librairie OpenLayers3. Vous pouvez également vous référer au même document adapté pour la librairie Leaflet.

Nous sommes désormais en mesure de construire des applications interactives pour le web depuis des services cartographiques, ce sera l’objet du prochain document de cette série qui détaillera la création d’une carte interactive sous la librairie Leaflet, le même principe pouvant s’appliquer à OpenLayers3 avec les techniques que nous venons d’expliquer.

Requête à un serveur cartographique sous client web Leaflet

Etude du code prototype projet Leaflet

Nous avons généré automatiquement dans QGIS un prototype qui permet de visualiser une carte au travers d’un client web, explorons le code…

Le meilleur moyen d’aborder Leaflet est de passer par le tutoriel de démarrage et d’explorer la librairie.

Concept

La composante de base de Leaflet est la carte (L.map). Cette carte est rendue dans un conteneur cible, en pratique dans un élément div de la page Web qui contient la carte.

Les propriétés de la carte peuvent être configurées au moment de sa construction, ou ultérieurement par des méthodes spécifiques.

Le conteneur cible qui a été généré pour nous est un élément div d’identifiant map

<div id="map"></div>

Ce conteneur possède un style d’affichage paramétré dans le fichier de style css/own_style.css, le style est paramétré ici pour un affichage à 100% de la taille de la fenêtre du navigateur.

...
	html, body, #map {
		height: 100%;
		width: 100%;
		padding: 0;
		margin: 0;
	}
...

Notez que la librairie Leaflet possède son propre fichier de style qui est chargé en ressource en entête de fichier html depuis un CDN.
Les fichiers de style des extensions Leaflet.markercluster et Leaflet.label sont également chargés, ainsi que la feuille de style personnalisée pour le projet.

        <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css" />
        <link rel="stylesheet" href="css/MarkerCluster.css" />
        <link rel="stylesheet" href="css/MarkerCluster.Default.css" />
        <link rel="stylesheet" type="text/css" href="css/own_style.css">
        <link rel="stylesheet" href="css/label.css" />

L.Map gère le niveau de zoom et la projection de la carte.

Pour obtenir les données à représenter pour une couche, Leaflet utilise des classes pour chaque type de couche vecteur, ou raster. Ces couches permettent d’alimenter la carte au travers de serveurs de tuiles, serveur WMS, images, données vectorielles et format GeoJSON. Voyons comment sont configurées les sources par défaut dans notre prototype.

Chargement des ressources javascript

Premièrement le prototype charge la librairie JQuery que l’on ne présente plus, puis la librairie Autolinker qui est un utilitaire qui permet de formater proprement des urls en javascript.

        <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
        <script src="js/Autolinker.min.js"></script>

Puis une il fait appel à la librairie Leaflet et à ses extensions, leaflet-hash, Leaflet.label et Leaflet.markercluster.

        <script src="http://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js"></script>
        <script src="js/leaflet-hash.js"></script>
        <script src="js/label.js"></script>
        <script src="js/leaflet.markercluster.js"></script>

Que font ces extensions ?

  • leaflet-hash va ajouter dynamiquement à l’url du navigateur une ancre qui permet aux utilisateurs de repérer une vue spécifique de la carte (par ex. #11/37.5508/-122.2895
  • Leaflet.label permet d’ajouter des étiquettes de texte aux marqueurs sur la carte et couches vectorielles
  • Leaflet.markercluster permet de manipuler des groupes de marqueurs de façon performante avec des animations Javascript

Le script charge ensuite les données de chaque couche, ces données ont été transformées au format Geojson par l’extension installée sous QGIS, ce sont ces données qui sont les sources de notre prototype par défaut.

Si l’on considère la couche qui comporte les informations départementales, le script va faire appel à la couche au format Géojson

<script src="data/exp_Departement.js"></script>

qui a pour contenu

var exp_Departement = {
   "type": "FeatureCollection",
   "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
   "features": [
 { "type": "Feature", "properties": { "color_qgis2leaf": 'none', "border_color_qgis2leaf": '#00ff00', "radius_qgis2leaf": 1.65, "transp_qgis2leaf": 0.75, "transp_fill_qgis2leaf": 1.0,   "id_geofla": 1, "code_dept": "01", "nom_dept": "AIN", "code_chf": "053", "nom_chf": "BOURG-EN-BRESSE", "x_chf_lieu": 8717, "y_chf_lieu": 65696, "x_centroid": 8814, "y_centroid": 65582, "code_reg": "82", "nom_region": "RHONE-ALPES" }, "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [ 5.831226413621037, 45.938459578293219 ], [ 5.82212102507634, 45.930135953523816 ], [ 5.829154566881813, 45.917237123525148 ], ... } },
                   ...

GeoJson est un format qui permet d’encoder des structures de données géographiques, nous ne nous attarderons pas sur le format, notre objectif est de remplacer ces données statiques par des données fournies dynamiquement par notre serveur cartographique.

Une fois les données chargées, le programme passe au travail d’initialisation et de rendu de la carte.

Initialisation et rendu de la carte

var map = L.map('map', {zoomControl: true}).fitBounds([[36.1453640773, -8.27037589111], [54.0946095281, 6.49685600911]]);

Le script commence par créer un objet carte L.map, il passe en paramètre l’identifiant de l’élément div qui va contenir la représentation ainsi qu’un tableau d’options. L’option zoomControl permet d’indiquer que le contrôle de gestion de l’agrandissement sera intégré à la carte.

La méthode fitBounds() est chaînée à la méthode de création, elle définit la vue de carte qui contient les limites géographiques données avec le niveau de zoom maximum possible.
Les limites sont exprimées en couples de points, latitude et longitude.

var hash = new L.Hash(map);

Cette instruction permet d’utiliser l’extension leaflet-hash pour ajouter dynamiquement une ancre nommée à l’url fonction de la vue en cours qui permet de partager facilement l’état de la vue sur la carte. Elle peut être ignorée.

Le script va ensuite construire la liste des couches à afficher, deux instructions se trouvent dans le code, l’une utilise un featureGroup, l’autre un LayerGroup. Seule la première est exploitée.

var feature_group = new L.featureGroup([]);

Va déclarer un groupe qui permet de manipuler plusieurs couche à la fois au sein d’un seul élément. Ce groupe gère les événements souris.

La première couche sert à la représentation de la carte de base, le fond de carte.

            var basemap_0 = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: additional_attrib + '&copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors,<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'});
            basemap_0.addTo(map);

L.tileLayer indique de charger une source de données depuis un serveur de tuiles (images pré-rendues).
La source de données est configurée pour interroger les serveurs de tuiles d’OpenStreetMap.
Le paramètre attribution est une chaîne de caractère représentatif qui sera affiché dans le contrôle qui gère les attributions
Ces attributions font parties des conditions d’utilisation d’OSM.

La couche est ajoutée directement à la carte.

Chaque couche de données est alors crée l’une à la suite de l’autre avec une source de données, un style et d’autres paramètres puis elle est ajoutée au groupe… qui au final n’est pas non plus utilisé ! L’extension sous QGIS manque d’aboutissement, et on doit faire abstraction de beaucoup de code généré. A chaque ajout de couche, la couche est temporisée dans un tableau, toutes les couches de la carte sont retirées et réinsérées dans l’ordre du tableau… passons, nous ne conserverons que les instructions d’ajout de couche à la carte.

Si l’on considère la couche des départements.

            var exp_DepartementJSON = new L.geoJson(exp_Departement, {
                onEachFeature: pop_Departement,
                style: function (feature) {
                    return {color: feature.properties.border_color_qgis2leaf,
                        fillColor: feature.properties.color_qgis2leaf,
                        weight: feature.properties.radius_qgis2leaf,
                        opacity: feature.properties.transp_qgis2leaf,
                        fillOpacity: feature.properties.transp_qgis2leaf};
                }
            });
...
map.addLayer(exp_DepartementJSON);

Ces instructions permettent de créer une couche de données vecteur, rendue côté client. La source de la couche est au format GeoJSON, elle accepte un objet GeoJSON, exp_Departement, qui a été construit lors de l’export du projet QGIS et chargé en amont dans les ressources Javascript.

L.geoJson() étend la classe featureGroup qui permet de gérer les événements souris.

Le style de rendu de la couche vectorielle est déclaré lors de la construction, il est fonction des éléments de la couche vectorielle, il est donc appliqué individuellement à chaque objet de la couche. Par défaut le code récupère le style depuis les propriétés exportées de l’extension Qgis2leaf, par exemple dans le Geojson vous pouvez trouver une propriété border_color_qgis2leaf positionnée à la couleur #00ff00.

Je peux modifier le style pour impacter tous les éléments avec pour l’exemple une couleur de remplissage rouge et une opacité qui permet de superposer cette couche au fond de carte.

                style: function (feature) {
                    return {color: feature.properties.border_color_qgis2leaf,
                        fillColor: '#FF0000',
                        weight: feature.properties.radius_qgis2leaf,
                        opacity: feature.properties.transp_qgis2leaf,
                        fillOpacity: 0.2};
                }

Remarquez alors le popup qui se déclenche au clic souris sur un département.

L’option onEachFeature permet d’indiquer une fonction qui sera appelée à la création d’une caractéristique vectorielle (chaque objet de la couche déclaré dans le format Geojson), ce qui permet d’attacher des événements aux caractéristiques vectorielles. Dans le code généré, une fonction est définie pour chaque couche, ici pop_Departement. Ce code ne fonctionne sur mon navigateur que si je modifie les styles pour l’affichage des départements. Il permet de manipuler chaque élément, par exemple d’afficher la propriété qui correspond au nom du département au clic sur le département sur la carte.

            function pop_Departement(feature, layer) {
                var popupContent = 'Departement ' + feature.properties['code_dept'];
                layer.bindPopup(popupContent);
            }

La fonction prend en argument les données géométriques et la couche vectorielle. Il est alors possible de récupérer les propriétés de l’objet, la méthode bindPopup() attache un événement au clic qui affichera le contenu html en paramètre dans un popup.

Ajout de contrôles à la carte

Un contrôle L.control( est un widget représenté à l’écran. Un contrôle peut être seulement informatif ou bien peut permettre d’interagir avec l’utilisateur. Nous avons vu lors de la création qu’un contrôle qui permettait de manipuler le niveau d’agrandissement était ajouté lors de la déclaration. Si vous jetez un œil à la carte vous verrez en fait quatre autres contrôles : le titre, l’échelle, les attributions et le contrôle qui permet de sélectionner les couches à afficher ou masquer.

Ces contrôles sont initialisés et ajoutés manuellement à la carte.

Contrôle pour affichage du titre

            var title = new L.Control();
            title.onAdd = function (map) {
                this._div = L.DomUtil.create('div', 'info');
                this.update();
                return this._div;
            };
            title.update = function () {
                this._div.innerHTML = '<h2>Etablissements scolaires de Gironde a 50 m parcelle cultivee</h2>Test client web leaflet'
            };
            title.addTo(map);

Le titre n’est pas un contrôle prédéfini, c’est un contrôle basique qui va implémenter l’interface IControl, qui lui donne accès à la méthode onAdd(). L’aspect visuel du contrôle est construit à l’ajout du contrôle à la carte, le but de la méthode d’ajout est de créer tous les éléments du DOM nécessaire pour le contrôle et d’ajouter des écouteurs d’événements, elle renvoie l’élément contenant le contrôle.

L’instruction L.DomUtil.create(‘div’, ‘info’) va créer dans le DOM un élément DIV de classe info.

L’élément est ensuite manipulé pour insérer un titre html.

Le système est souple et accorde beaucoup de liberté, la position du contrôle est quand à elle fournie en option à la création, à l’ajout à la carte, ou encore précisée post création.

La position est paramétrable dans les angles (haut droit, bas droit, haut gauche, bas gauche). Les contrôles sont rendus par des styles css qui permettent de gérer les marges par exemple, remarquez que le contrôle d’affichage du titre et le contrôle de visualisation des couches sont tous deux dans le coin haut droit, et empilés l’un au dessous de l’autre.

Contrôle pour affichage de l’échelle

L.control.scale({options: {position: 'bottomleft', maxWidth: 100, metric: true, imperial: false, updateWhenIdle: false}}).addTo(map);

L.control.scale() est un contrôle prédéfini qui permet l’affichage d’une échelle.

Contrôle pour visualisation des couches

L.control.layers(baseMaps, {"etabparcelinters50m": exp_etabparcelinters50mJSON, "etabssel": exp_etabsselJSON, "etabstamponselec": exp_etabstamponselecJSON, "DepartementWFS": exp_DepartementWFSJSON, "Departement": exp_DepartementJSON}, {collapsed: false}).addTo(map);

L.control.layers() est un contrôle prédéfini qui permet de sélectionner les couches représentées ou pas. Ce contrôle possède une option collapsed qui permet de représenter le contrôle sous forme d’icône cliquable, le contrôle s’ouvre ou se referme au clic.

Contrôle pour affichage des attributions

Le contrôle est créé et positionné par défaut sur la carte à moins de demander explicitement le contraire lors de la création de carte grâce au paramètre attributionControl positionné à faux. Les attributions sont récupérées des options de chaque couche. (La valeur pour la couche OSM est renseignée à la création)

L.control.attribution() permet de créer le contrôle manuellement, afin par exemple de modifier sa position.

Adaptation du code pour une source de données par serveur cartographique

Nous avons a disposition pour le projet deux services qui tournent sous un serveur cartographique QGIS Server.

Les capacités WFS et WMS des services peuvent être interrogées par les url suivantes

http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities
http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities

C’est ce dernier service que l’on va exploiter en premier.

Appel à un service WMS QGIS Server sous Leaflet

Le nom de la couche des départements nous intéresse particulièrement, on peut le récupérer depuis le fichier xml généré par la requête aux capacités du serveur.

<Layer queryable="1">
<Name>Departement</Name>
<Title>Departement</Title>
<CRS>EPSG:4326</CRS>
<EX_GeographicBoundingBox>
<westBoundLongitude>-5.21251</westBoundLongitude>
<eastBoundLongitude>9.63332</eastBoundLongitude>
<southBoundLatitude>41.3141</southBoundLatitude>
<northBoundLatitude>51.1376</northBoundLatitude>
</EX_GeographicBoundingBox>
<BoundingBox CRS="EPSG:4326" maxx="51.1376" minx="41.3141" maxy="9.63332" miny="-5.21251"/>
<Style>
<Name>default</Name>
<Title>default</Title>
<LegendURL>
<Format>image/png</Format>
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple" xlink:href="http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&LAYER=Departement&FORMAT=image/png&STYLE=default&SLD_VERSION=1.1.0"/>
</LegendURL>
</Style>
</Layer>

Leaflet est en mesure de charger des sources de données depuis un serveur cartographique via le service WMS, il utilise à cette fin TileLayer.WMS.

Nous pouvons remplacer la source de données Geojson par notre source WMS, la déclaration de la source se fait ainsi :

            var wmsDepts = L.tileLayer.wms("http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi", {
                layers: 'Departement',
                format: 'image/png',
                transparent: true,
            });

Le serveur cartographique retourne une image, de ce fait on perd les possibilités offertes par la couche vectorielle Geojson. L’avantage d’une image est souvent sa légèreté sur le réseau par rapport à un fichier de données, mais le format vectoriel nous permet d’interagir en javascript avec les objets vectoriels, on peut par exemple sélectionner un objet et modifier son style, supprimer ou ajouter un objet.

Interagir avec les objets peut permettre de créer des cartes plus interactives, en créant des composants de formulaire en widget par exemple on peut imaginer de représenter dynamiquement une carte choroplèthe ou des symboles proportionnels en fonction d’un paramètre sélectionné et d’une requête asynchrone qui va chercher les valeurs d’indicateurs à représenter.

Appel à un service WFS QGIS Server sous Leaflet

Si l’on regarde attentivement le code généré par l’extension sous QGIS, la solution est déjà implémentée mais ne fonctionne pas, le code tente non pas d’accéder au service WMS mais bien au service WFS paramétré sous QGIS. Il va effectuer une requête asynchrone en javascript sur le service WFS (de type GetFeature), le résultat est exploité comme couche vectorielle.

Comment s’y prend-il ?

            var DepartementWFSURL = 'http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.0.0&REQUEST=GetFeature&TYPENAME=Departement&SRSNAME=EPSG:4326&outputFormat=text%2Fjavascript&format_options=callback%3AgetDepartementWFSJson';
            DepartementWFSURL = DepartementWFSURL.replace(/SRSNAME\=EPSG\:\d+/, 'SRSNAME=EPSG:4326');

Premièrement l’url d’appel au service WFS est construite. Cette url comporte le paramètre format_options qui ne fait pas parti du standard WFS, mais est interprété sur un serveur cartographique Geoserver. Ce paramètre permet au serveur d’encapsuler les données au format JSON dans une fonction javascript, les données retournées sont donc indiquées au format de sortie text-javascript. Ce mécanisme, nommé sous l’appellation JSONP, permet de contourner les appels de requêtes sur domaines croisés.

Si l’on effectue une requête sur notre serveur afin de récupérer des données JSON depuis une page html côté client (en Ajax donc), le navigateur va interdire l’opération si le serveur n’autorise pas explicitement les accès en origine croisée par l’ajout d’une entête de contrôle d’accès.

Notre problème est double. Premièrement QGIS Server possède les capacités pour servir du GeoJSON, qui est simplement du JSON avec une convention, par contre il ne propose pas le format de sortie JSONP qui encapsulerait simplement le GeoJSON dans une fonction Javascript. Il nous resterait comme option de configurer le serveur pour accepter les requêtes d’origine croisées (CORS), cependant le serveur QGIS fonctionne en mode CGI et Apache ne peut pas à ma connaissance ajouter une entête d’autorisation sur la sortie produite par QGIS Server.

La solution lorsque l’on ne peut ni modifier le format de sortie ni configurer le serveur pour ajouter des autorisations est d’utiliser un proxy. Très simplement un script sera hébergé sur le même serveur, il acceptera en paramètres un nom de fonction qui servira à l’encapsulation javascript du format JSON, et l’url qu’il sera en mesure d’interroger depuis le même domaine.

Corrigée pour le serveur QGIS, l’url qui permet de récupérer les objets de la couche des départements est la suivante :

var DepartementWFSURL = 'http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.0.0&REQUEST=GetFeature&TYPENAME=Departement&SRSNAME=EPSG:4326&outputFormat=GeoJSON';

J’ai créé un simple script en langage PHP qui servira de proxy. Vous pourrez probablement trouver des solutions plus complètes…

<?php
if(!isset($_GET['callback']) || !isset($_GET['url'])) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
extract(parse_url($_GET['url']));       

if (! ( in_array($host, array($_SERVER['HTTP_HOST'], $_SERVER['SERVER_ADDR']) )
      || ( basename($path) != 'qgis_mapserv.fcgi' ) )) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
if (!$content = file_get_contents(filter_var($_GET['url'], FILTER_SANITIZE_URL))) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
header('content-type: application/javascript; charset=utf-8');
header("access-control-allow-origin: *");
echo filter_var($_GET['callback'], FILTER_SANITIZE_ENCODED), '(', $content, ');';

Ce script nommé proxy.php est placé à la racine du répertoire web. Il doit être appelé avec deux paramètres, callback et url.

Le paramètre url doit être encodé…

http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=...

Le script effectue quelques contrôles, premièrement il n’aboutit pas si les paramètres ne sont pas renseignés. Il vérifie ensuite que l’url à interroger est sur le même domaine que le serveur, puis vérifie que l’on cherche bien à interroger un script CGI qui correspond à QGIS Server. Le script filtre ensuite les paramètres et exécute la requête. Le contenu de la requête est retourné encapsulé dans la fonction de rappel Javascript avec les bons entêtes : premièrement l’entête d’application javascript (et non pas l’entête d’application JSON), puis l’entête de contrôle d’accès qui autorise le proxy à être interrogé depuis un autre domaine.

Bien… ce problème réglé le code peut fonctionner comme attendu.

            var proxy = 'http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=';
            var proxyURL = proxy + encodeURIComponent(DepartementWFSURL);

Construit l’url d’appel à notre proxy en précisant la fonction de rappel et l’url d’origine du service.

            var exp_DepartementWFSJSON = L.geoJson(null, {
                style: function (feature) {
                    return {color: '#00ff00',
                        fillColor: '#ff0000',
                        weight: 1.65,
                        opacity: 0.75,
                        fillOpacity: 0.10};
                },
                onEachFeature: function (feature, layer) {
                    var popupContent = 'Departement ' + feature.properties['code_dept'];
                    layer.bindPopup(popupContent);
                }
            });

Le code commence par créer une couche GeoJSON vide. Le mécanisme est le même que nous avons rencontré précédemment, avec application d’un style (sur mon navigateur le style de remplissage doit être renseigné pour que le popup fonctionne), et application d’une fonction javascript à exécuter sur chaque objet de la couche vectorielle. Comme précédemment on créé un popup au clic souris sur le département pour afficher son code.

Cette couche vide sera remplie ultérieurement par un appel javascript asynchrone qui va récupérer les objets de la couche vectorielle depuis le service WFS.

            var DepartementWFSajax = $.ajax({
                url: proxyURL,
                dataType: 'jsonp',
                jsonpCallback: 'getDepartementJson',
                success: function (response) {
                    L.geoJson(response, {
                        onEachFeature: function (feature, layer) {
                            exp_DepartementWFSJSON.addData(feature)
                        }
                    });
                }
            });

            exp_DepartementWFSJSON.addTo(map);

L’appel Ajax accepte ces paramètres :

  • url : url vers notre proxy qui interrogera le serveur WFS
  • dataType : notre proxy retourne du javascript au format JSONP, on doit préciser le type de données car les entêtes retournées sont application javascript
  • jsonpCallback : le nom de la fonction de rappel dans le fichier JSONP retournée par le proxy
  • success : En cas de réussite une fonction anonyme est exécutée avec la réponse en paramètre, notre GeoJSON… la réponse est parcourue dans une couche L.geoJson qui duplique chaque objet dans la couche vide

Le développeur de l’extension d’export sous QGIS a fait le choix de dupliquer la couche en cas de réussite ce qui permet d’initialiser une couche vectorielle y compris au cas où la requête Ajax échoue.

Nous avons fait le tour des codes sources générés sous une extension QGIS pour la librairie Leaflet. Vous pouvez également vous référer au même document adapté pour la librairie OpenLayers3.

Nous sommes désormais en mesure de construire des applications interactives pour le web depuis des services cartographiques, ce sera l’objet du prochain document de cette série qui détaillera la création d’une carte interactive sous la librairie Leaflet.

Utilisation des services WMS et WFS sous un client web Leaflet ou OpenLayers 3

Utilisation des services sous un client web

Nous avons vu comment créer une carte sous le logiciel client QGIS et comment publier le projet sous le serveur cartographique QGIS server au travers de deux types de services, WMS et WFS. Nous sommes donc en mesure de servir des données cartographiques au travers du Web. Ce n’est que la face cachée de l’iceberg, nous allons exploiter ces services pour construire une application de rendu de carte dynamique dans un navigateur web.

Les solutions clientes qui permettent le rendu de cartes interactives sur un navigateur sont des librairies en langage Javascript. Nous couvrirons deux de ces librairies : premièrement la librairie Leaflet qui a été conçue après l’apparition du HTML5 dans un souci de simplicité et d’efficacité, puis on s’attardera sur la référence dans le domaine, la librairie OpenLayers. Pour cette dernière j’emploierai la version 3 qui est une refonte moderne de la version 2, désormais obsolète pour les futur développements.

QGIS possède des extensions pour ces deux librairies qui permettent de générer un squelette de projet web très simplement à partir du projet en cours. Nous allons utiliser ces extensions qui nous facilitent le travail pour initialiser avec les bons paramètres les couches du projet à charger. Ces extensions permettent de générer dans un fichier au format Json les données de couches exploitées par la librairie javascript, alors que l’on souhaite interroger le serveur cartographique. Nous testerons la capacité de ces extensions à générer du code d’appel aux services WFS puis nous verrons en détail le code source nécessaire pour interroger un serveur distant.

Rappels sur le projet de test

Le projet de test est celui que nous avons publié sur le serveur cartographique. Il comporte cinq couches destinées à mettre en évidence les intersections des collèges et lycées avec des parcelles cultivées dans un rayon de 50 mètres, sur le département de la Gironde.

Les couches Departement et cultures_33 servent à représenter les limites administratives, et limites de parcelles. Elles sont récupérées depuis la base de données. Les autres couches sont des couches projet enregistrées au format fichier Shapefile, elles servent à matérialiser les établissements que l’on a identifiés : la couche etabs_sel permet d’afficher un point aux coordonnées de latitude et longitude de l’établissement, la couche etabs_tampon_selec sert à délimiter le périmètre autour de l’établissement et finalement la couche etab_parcel_inters_50m met en évidence l’intersection entre la parcelle de culture et la zone tampon de 50 mètres autour de l’établissement.

Préparation du projet de test

Dupliquons dans le projet la couche des départements chargée dans la base locale avec la même couche mais chargée cette fois depuis le serveur cartographique.
Cette manipulation permettra de tester la capacité des extensions QGIS à générer le code client pour l’exploitation du service.

Ouvrez le projet QGIS que vous avez publié sur le serveur cartographique et enregistrez le sous un autre nom
(ex. C:\SIG\projet_1\projet_1.qgs vers C:\SIG\projet_1\projet_1_distant.qgs)

Ajoutez la couche WFS des départements (Depuis l’icône à gauche ou bien Couche > Ajouter une couche WFS)

Vous devriez avoir désormais deux couches de départements… il vous suffit de copier le style de la première et de l’appliquer à la seconde.

Faites un clic droit dans l’explorateur de couche sur la couche initiale, sélectionnez l’entrée de menu Copier le style.
Procédez de même sur la couche de destination pour Coller le style.

Modifiez le nom de la couche de destination puis enregistrez !

Préparation du projet sous QGIS pour le client Leaflet

Sous QGIS, ouvrez le gestionnaire d’extension depuis le menu principal
Extension > Installer / Gérer les extensions
Recherchez et installez l’extension qgis2leaf
Ouvrez le projet QGIS que vous avez publié sur le serveur cartographique.
(ex. C:\SIG\projet_1\projet_1.qgs)

Depuis le menu principal vous pouvez alors créer une carte web
Internet > qgis2leaf > Export a QGIS project to a working leaflet webmap

Je n’ai renseigné qu’un minimum d’options afin dans un premier temps de générer le moins de code possible :

  • Appuyer sur le bouton Get Layers permet de charger vos couches dans l’extension. Vous pouvez sélectionner les couches à exporter, ici j’ai sélectionné toutes les couches
  • Le champ Frame width / height permet de déterminer les dimensions de la carte sur la page web, j’ai sélectionné le champ Full screen pour utiliser tout l’écran
  • Le champ Extent permet de définir l’étendue géographique initiale à l’arrivée sur l’application, par défaut elle est positionnée à l’étendue du canvas en cours, ici je me suis positionné sur une vue globale centrée sur la Gironde
  • Le champ Basemaps permet de faire une sélection multiple de cartes à importer pour le fond de carte, pour l’habillage j’utilise le fond de carte OpenStreetMap OSM Standard

Le projet est enregistré et ouvert dans un navigateur web, le résultat est fonctionnel on peut masquer des couches et zoomer. Par contre la couche des cultures est affichée quelle que soit l’étendue géographique, alors que l’on avait paramétré dans le projet sa visibilité. Le chargement très long de la page est lié à la quantité de données de cette couche chargée intégralement et sera réduit lorsqu’on chargera la couche depuis le service web en fonction de l’étendue géographique. Notez également que la couche WFS des départements n’est pas rendue…

Pour terminer avec les problèmes relevés : l’étendue initiale du canevas n’est pas respectée.

Ce premier rendu est prometteur, nous rentrerons dans un second temps dans le code qui a été généré, pour le moment voyons en parallèle comment arriver aux mêmes résultats avec une autre librairie.

Préparation du projet sous QGIS pour le client OpenLayer 3

Sous QGIS, ouvrez le gestionnaire d’extension depuis le menu principal
Extension > Installer / Gérer les extensions
Dans l’onglet Paramètres cochez la case pour afficher les extensions expérimentales.
Recherchez et installez l’extension Export to OpenLayers 3
Ouvrez le projet QGIS préparé précédemment. Vous pouvez alors créer une carte web OpenLayers3
Internet > Export to Open Layers > Create OpenLayers map

J’ai laissé les paramètres par défaut devant le peu de choix et l’absence d’aide à l’utilisation. L’extension s’exécute, cela requiert du temps à nouveau en raison de la couche des cultures qui représente 20 Mo de données au format Json. La carte possède les mêmes qualités et défauts que la carte générée pour la librairie Leaflet, c’est à dire que les cultures sont représentées indépendamment de l’échelle, le service WFS n’est pas non plus exploité car les données ont été converties au format Json.

Nous avons vu comment générer les squelettes des codes sources qui permettent d’utiliser des clients web pour l’affichage cartographique. Pour chaque solution nous allons explorer plus en détail le code afin d’expliquer le fonctionnement des librairies et comment nous pouvons remplacer les données générées en dur par des données issues des services que l’on a mis en place.

Installation de QGIS Server et publication d’un projet QGIS

QGIS Server

Introduction

QGIS Server est une application qui va agir en service et répondre à des requêtes http qui suivent un standard conçu pour les échanges de données géospatiales sur le web.

Il s’installe en application FastCGI/CGI sur un serveur web et va générer des réponses aux formats standards qui permettrons d’échanger des données, de visualiser et publier des cartes sur Internet à partir de données géographiques.

Ce serveur est conçu à partir des librairies du projet QGIS, il possède le même moteur de rendu cartographique que l’application QGIS Desktop et permet de publier très simplement un projet QGIS Desktop vers le Web.

Le serveur cartographique possède 2 services nommés WMS et WFS que l’on utilisera au travers de clients web (Leaflet et Openlayers 3)

Serveur WMS

QGIS Server implémente le standard WMS (Web Map Service) publié par l’Open Geospatial Consortium (OGC). WMS est un service web mis en oeuvre au travers d’une architecture REST, il est possible de l’interroger par des requêtes HTTP. Le serveur va répondre aux requêtes, l’objet du service WMS est simplement de renvoyer une image : le serveur analyse la requête, détermine les paramètres, et en fonction, il va construire une carte raster à partir des sources de données dont il dispose.

Les opérations basiques du WMS sont :

  • GetCapabilities : Retourne les métadonnées sur les capacités du service. (Les paramètres de requête acceptés par le serveur)
  • GetMap : Retourne une carte (au format d’image) avec des paramètres définis, dimensions et informations
  • GetFeatureInfo : Retourne des informations sur les entités d’une carte

Serveur WFS et WFS-T

QGIS Server implémente également le standard WFS (Web Feature Service) publié par l’OGC. Contrairement au WMS, le WFS n’implique pas le rendu d’une carte par le serveur, il s’agit en fait d’une requête de récupération d’informations sous forme vectorielle. Les entités d’une couche sous QGIS peuvent être communiquées sous forme xml au client qui en fait la demande. Le client du service peut alors utiliser le service WFS pour effectuer par exemple un rendu de carte.

WFS-T est une version transactionnelle qui autorise la création, suppression, et mise à jour d’entités.

Référencez vous aux documents de standards pour les paramètres de construction de requêtes http

Et quoi d’autre ?

Eh bien à vous de découvrir… nous avons presque tout ce qu’il faut pour construire une application cartographique, les autres capacités du serveur ont peu d’importance dans ce contexte.

Nous verrons à partir d’un client web comment interroger les serveurs de tuile d’OpenStreetMap pour le rendu d’un fond de carte, QGIS Server n’a pas les capacités de serveur de tuiles à l’heure actuelle. En fin de projet je testerai l’installation des données OSM sur un serveur qui ne sera dédié qu’à cette opération. Il pourra être utilisé en remplacement des serveur OSM afin de ne pas pénaliser ceux ci.

Installation

Sous Windows

Vous pouvez installer QGIS Server en local sur votre poste de travail Windows, vous pouvez utiliser l’outil d’installation OSGeo4W fourni par la Fondation Geospatiale Open Source (OSGeo)

L’installateur propose une liste de paquets logiciels qui permet d’installer les logiciels et leurs dépendances. Le serveur QGIS se situe sous l’arborescence Web.

L’installation embarque une version du serveur web Apache 2 qui est installée comme un service, lancé au démarrage. Vous pouvez gérer le serveur Apache depuis une entrée du menu Démarrez.

Après installation vous devrez renseigner le port d’écoute d’Apache, particulièrement si vous possédez déjà des serveurs par défaut sur le port 80.

Il suffit d’éditer le fichier C:\OSGeo4W\apache\conf et remplacer la ligne

Listen @apache_port_number@

par votre port d’écoute. Par ex. sur le port 80

Listen 80

Redémarrez Apache depuis l’entrée de menu
Tous les programmes > OSGeo4W > Apache

Sous Debian Wheezy

Debian Wheezy possède des paquets compilés disponible via l’outil d’installation, ce qui rend la tâche en théorie triviale, cependant nous verrons plus loin que le paquet par défaut propose une ancienne version du serveur qui ne possède pas le support WFS.

La commande installera également Apache 2 avec un module capable de gérer le FastCGI.

aptitude install qgis-mapserver libapache2-mod-fcgid

Autoriser le module CGI

a2enmod cgid

Redémarrer Apache

service apache2 restart

Et c’est tout ? Oui. Sous Debian, et pour la version du serveur qui ne propose que le service WMS.

Continuez la lecture ! Vous trouverez plus loin les instructions pour installer la dernière version stable afin de profiter du support WFS

J’ai moi même procédé en 2 étapes et je préfère conserver une trace du cheminement complet afin d’aider les personnes qui pourraient se poser les mêmes questions.

Le script qgis_mapserv.fcgi est placé sous le répertoire /usr/lib/cgi-bin/, c’est lui que l’on va interroger.

Tester l’installation

Le serveur tourne, il doit répondre aux requêtes selon des standards précis. On peut interroger le serveur et lancer une requête http GetCapabilities sur les services WMS et WFS pour qu’il nous informe de ce qu’il est capable de faire.

Par défaut sous Windows

http://localhost/qgis/qgis_mapserv.fcgi.exe?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities
http://localhost/qgis/qgis_mapserv.fcgi.exe?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities

Par défaut sous Debian

http://92.222.27.205/cgi-bin/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities
http://92.222.27.205/cgi-bin/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities

Nous allons voir comment ajouter un premier projet QGIS sur le serveur.

Mettre en ligne un projet QGIS Server

Préparation du projet sous QGIS

L’objet de ce premier projet sera de vérifier le fonctionnement du serveur, on se contentera d’afficher sur une carte les départements et les établissements scolaires à moins de 50 mètres d’une parcelle agricole sur le département 33

Ouvrez un nouveau projet sous QGIS. (Par défaut votre SCR doit être EPSG:4326)
Chargez les couches PostGIS des établissements, des départements, et de la parcelles pour le département 33 (GIRONDE) :

Couche > Ajouter une couche PostGIS

Le style de rendu des départements doit surtout mettre en avant le contour de polygone pour l’information de limite administrative.

Le contenu du polygone n’apporte pas d’information dans ce cas aussi nous allons le rendre opaque et accentuer les contours.

Ouvrez les propriétés de la couche Departement. (Il suffit de faire un double clic sur le nom de la couche dans l’explorateur de couche)
Dans la fenêtre de propriétés, ouvrez l’onglet Style.

Paramétrez le style de remplissage sur Bordure : Ligne simple pour le type de symbole.

Vous pouvez également modifier la couleur de remplissage et la transparence de la couche pour améliorer le rendu.

A ce stade vous pouvez enregistrer le projet (ex. C:\SIG\projet_1\projet_1.qgs)

La distance de 50 mètres autour d’un établissement peut se matérialiser approximativement par une zone tampon autour du point. Une zone tampon constitue une délimitation autour d’un objet géographique qui épouse la forme de l’objet. Autour d’un point une zone tampon aboutira plus ou moins à un cercle ayant pour centre l’objet point et pour rayon la distance tampon. La création d’une zone tampon permettra de déterminer s’il y a une intersection du tampon avec les objets de type polygone qui représentent les parcelles.

Ouvrez la fenêtre de création de tampons :
Vecteur > Outils de géotraitement > Tampon(s)

Renseignez le formulaire :

  • Couche vectorielle de saisie : il s’agit de la couche autour de laquelle seront crées les zones tampon
  • Segments pour l’approximation : plus la valeur est élevée plus les contours de la zone tampon seront arrondis
  • Distance tampon : Le système de coordonnées de référence du projet est WGS 84, l’unité du canevas est de ce fait exprimée par défaut en degrés décimaux ce qui n’est pas pratique pour matérialiser une distance en mètres ! Une valeur de 0.0005 donnera un résultat acceptable pour ce simple prototype. C’est très dommage que QGIS n’indique pas l’unité utilisée à cet endroit
  • Fichier de sortie : Choisissez l’emplacement de la couche qui sera créée, par exemple sous le répertoire projet C:/SIG/projet_1/etab_tampon_50m.shp
  • Ajouter le résultat au canevas

Si l’on utilise l’outil de mesure inclus dans QGIS on peut se rendre compte que le rayon de la zone tampon varie autour de 50 mètres (entre 40 et 56 mètres), c’est plus flagrant si l’on diminue le nombre de segments pour l’approximation dans la fenêtre de paramétrage de création du tampon.

L’intersection est l’opération qui va permettre de créer une couche avec les parties communes à la couche tampon autour des établissements et la couche de parcelles.

Ouvrez la fenêtre de création d’intersection :
Vecteur > Outils de géotraitement > Intersection
Dans le formulaire qui s’ouvre alors, sélectionnez la couche des tampon et celle des parcelles de la Gironde puis ajoutez le résultat au canevas

N’essayez pas sur les parcelles pour la France entière, vous perdriez certainement votre temps…

Il reste alors à distinguer les établissements pour lesquels il existe une intersection. On peut pour cela ajouter une jointure sur le numéro d’établissement entre la couche des établissements et la couche des intersections puis à partir des attributs appliquer un filtre de sélection qui va restreindre les éléments sélectionnés sur la base de la base de l’existence des attributs de la table jointe.

Créer une jointure dans les propriétés de la couche établissement


Procéder à la sélection dans la table des attributs à l’aide d’un filtre avancé sous forme d’expression

NOT  "etab_parcel_inters_50m_appellatio"  IS NULL

Il suffit alors d’enregistrer la sélection en sauvegardant la couche établissement sous un autre nom et en précisant de n’enregistrer que les entités sélectionnées.

A ce stade j’ai supprimé la couche d’établissements du projet et la couche de tampons, puis appliqué un style à la nouvelle couche d’établissements à moins de 50 mètres d’une parcelle en Gironde.

Dernier point, j’ai rendu la visibilité des parcelles dépendante de l’échelle afin de n’afficher les parcelles que lorsque l’utilisateur aura suffisamment zoomé sur une école. Cela peut se faire à partir des propriétés générales de la couche.

Ce projet est presque prêt à être publié, il reste à paramétrer les propriétés du serveur dans les propriétés du projet.

Publication du projet sous QGIS Server

Paramétrer le projet pour publication

Ouvrez les propriétés du projet (CTRL + MAJ + P)
Projet > Propriétés du projet

Sous l’onglet Serveur OWS vous trouverez quatre rubriques, les plus importantes pour nous étant les capacités WMS et WFS

Sous la rubrique Informations générales du service vous pouvez instruire des informations qui seront communiquées par le service, c’est utile si vous destinez les services à des clients web externes à votre organisation. Vous pouvez renseigner ici à minima les informations pour vous contacter.

Faites un clic droit sur la couche Departement, et sélectionnez l’entrée de menu Zoomer sur la couche

L’emprise du projet est adaptée en conséquence, nous avons alors une vue globale de la France métropolitaine, ce sera notre vue par défaut.

Sous la rubrique Capacités WMS, cochez la case Emprise annoncée, et cliquez sur le bouton Utiliser l’emprise actuelle du canevas

Restreignez les SCR au SCR utilisé pour le projet (EPSG:4326), sans quoi le service listera tous les SCR lors d’une requête d’information sur ses capacités.

Sous la rubrique Capacités WFS vous pouvez sélectionner les couches que vous souhaitez publier en WFS, les colonnes Mise à jour, Insérer et Effacer permettent de sélectionner finement les opérations transactionnelles à autoriser (WFS-T).

Cliquez sur le bouton Sélectionner tout pour publier toutes les couches. Nous ne souhaitons pas publier en mode transactionnel, vous pouvez appliquer et enregistrer le projet

A ce stade le projet peut être mis en publication sous le serveur QGIS mais il comporte encore beaucoup d’informations qu’il n’est pas utile de transmettre sur une connexion web.

Pour la plupart des couches il est inutile de transmettre tous les attributs (c’est d’autant plus vrai que l’on a à chaque fois conservés tous les attributs lors des jointures), je supprime par défaut tous les attributs et n’autorise qu’un nombre restreint d’attributs qui pourraient utiles à l’application.

Faites un clic droit sur la couche Departement, et sélectionnez l’entrée de menu Propriétés

Sous l’onglet Champs QGIS dresse la liste des attributs d’une couche, avec pour dernières colonnes des cases à cocher qui permettent de publier ou pas l’attribut en service WMS et WFS.

Décochez les champs superflus, et procédez de même pour les autres couches.

Enregistrez le projet !

Cette fois ci, le projet est prêt ! Passons à la mise en route.

Prise en charge du projet par le serveur

Sous Windows

Pour ajouter le projet au serveur il suffit de créer un répertoire projet sous C:\OSGeo4W\apps\qgis\bin, copier l’exécutable CGI sous ce répertoire ainsi que le fichier projet *.qgs et les ressources qu’il utilise.

Lors de la création du projet j’ai enregistré le projet au même endroit que les couches créées, QGIS s’appuies sur un chemin relatif pour accéder aux ressources, il utilisera le chemin ./ pour accéder à chaque ressource, il vous incombe de respecter le chemin relatif vers vos données. Continuez la lecture, je vous montrerai plus loin comment modifier la source d’une couche pour un projet QGIS !

Si vous possédez une installation cygwin (Sinon c’est aussi rapide à la souris mais avec moins de valeur documentaire !):

cd C:/OSGeo4W/apps/qgis/bin
mkdir projet1
cp qgis_mapserv.fcgi.exe projet1/qgis_mapserv.fcgi.exe
cp C:/SIG/projet_1/* projet1

Vérifier le fonctionnement

WMS

http://localhost/qgis/projet1/qgis_mapserv.fcgi.exe?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities

Et pour ne pas vous décevoir, une carte… cette url est construite d’après les paramètres retournés par la requête GetCapabilities, à vous de fouiller pour trouver où.

http://localhost/qgis/projet1/qgis_mapserv.fcgi.exe?SERVICE=WMS&VERSION=1.3.0&CRS=EPSG:4326&REQUEST=GetMap&BBOX=-5.82882,41.1196,10.2496,51.3322&WIDTH=800&HEIGHT=800&LAYERS=Departement,etab_parcel_inters_50m,etabs_tampon_selec,etabs_sel&FORMAT=image/png&STYLE=

WFS

http://localhost/qgis/projet1/qgis_mapserv.fcgi.exe?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities

Si cela vous amuse vous pouvez ouvrir un nouveau projet sous QGIS et tenter d’accèder au service :
Couche > Ajouter une couche WFS

Vous pouvez alors vous connecter, sélectionner toutes les couches à importer et vérifier le résultat.

Sous Debian

Pour les besoins du projet j’ai travaillé sur une connexion PostGIS locale, cela implique que le projet pourrait ne pas fonctionner si je le transfère sur un serveur.

Vous pouvez modifier les sources de données pour chaque couche en éditant le fichier projet *.qgs avec n’importe quel éditeur de texte, il s’agit simplement d’un format xml.

Il suffit de rechercher et adapter les lignes de ce type

<datasource>dbname='Pesticides' host=localhost port=5432 user='postgres' password='postgres' sslmode=disable key='id_geofla' srid=4326 type=MULTIPOLYGON table="public"."Departement" (geom) sql=</datasource>

ou encore

<datasource>./etab_parcel_inters_50m.shp</datasource>

Dans l’immédiat il me manque la table des parcelles sur la Gironde sur le PostgreSQL distant, l’utilitaire de sauvegarde et restauration de PostgreSQL pg_dump peut me permettre d’exporter cette table vers un fichier sql.

cd C:\SIG\sql
pg_dump --host=localhost --port=5432 --username=postgres --dbname=Pesticides --no-password --table=cultures_33 > db.sql

Sous le serveur distant, la restauration se fait par :

su - postgres
psql --dbname=Pesticides --file=db.sql

Une fois l’environnement dupliqué l’installation du projet est très simple.

Il suffit comme sous Windows de créer un répertoire et de copier sous ce répertoire le fichier projet *.qgs et les ressources. Un lien symbolique doit suffire pour donner accès au script CGI depuis le répertoire projet.

mv projet1 /usr/lib/cgi-bin/
cd /usr/lib/cgi-bin/projet1
ln -s ../qgis_mapserv.fcgi .
ln -s ../wms_metadata.xml .

Vérifier le fonctionnement

WMS

http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities
http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&CRS=EPSG:4326&REQUEST=GetMap&BBOX=41.1196,-5.82882,51.3322,10.2496&WIDTH=800&HEIGHT=800&LAYERS=Departement,etab_parcel_inters_50m,etabs_tampon_selec,etabs_sel&FORMAT=image/png
Pour construire cette dernière url j’ai du inverser l’axe x,y du paramètre BBOX, autrement le serveur retournait une image vide. Je ne trouves pas les informations pour paramétrer le serveur sous Debian, mais à priori cela correspond à la version 1.1.0 du service, le numéro de version semble tout simplement ignoré.
WFS : Attention problème ! Si vous testez le lien suivant le serveur répond avec les capacités WMS, le paramètre est simplement ignoré, la version du serveur est trop ancienne !
http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities

Mais pourquoi ça ne fonctionne jamais du premier coup ?

Installer la dernière version stable de QGIS Server sur Debian Wheezy

En prérequis vous devrez avoir installé et configuré le paquet libapache2-mod-fcgid comme expliqué en amont.

Par défaut sous Debian Wheezy l’installeur ne propose pas la dernière version du serveur QGIS… en fait le paquet qgis-mapserver a été remplacé par le paquet qgis-server.

Retirez si besoin le paquet obsolète
apt-get remove qgis-mapserver

Il faut ensuite indiquer à Debian où récupérer les dernières versions stables du nouveau paquet.

Éditez le fichier de sources du gestionnaire de paquets
vi /etc/apt/sources.list.d/pgdg.list
Insérez les lignes suivantes : (Consultez la documentation QGIS pour votre version Debian)
deb     http://qgis.org/debian wheezy main
deb-src http://qgis.org/debian wheezy main
Récupérez la clef de sécurité et mettez à jour les informations dans le gestionnaire de paquets
gpg --keyserver keys.gnupg.net --recv-key DD45F6C3
gpg --export --armor DD45F6C3 | apt-key add -
apt-get update
Procédez à l’installation
apt-get install qgis-server
apt-get install build-essential python-dev

Cette dernière commande permet de régler des problèmes de fonctionnement, vous en aurez probablement également besoin.

Nous avions déjà tout paramétré, il ne vous reste qu’à reprendre à partir de l’étape de vérification du fonctionnement et vous pouvez désormais vérifier le chargement des couches WMS et WFS depuis QGIS.

En cas de problème

Les fichiers de log du script serveur qgis-server peuvent servir à résoudre des problèmes… encore faut-il savoir les trouver !

Sous Apache et avec le mode fcgid, vous pouvez éditer le fichier de configuration Apache pour insérer deux directives.

FcgidInitialEnv QGIS_SERVER_LOG_FILE /tmp/qgisserver.log
FcgidInitialEnv QGIS_SERVER_LOG_LEVEL 0

Remarquez qu’en dehors du répertoire temporaire je n’ai pas réussi à faire fonctionner ces directives.

Côté performances vous pouvez également paramétrer le mode fcgid.

vi /etc/apache2/mods-enabled/fcgid.conf

Ma configuration est la suivante :

<IfModule mod_fcgid.c>
  AddHandler    fcgid-script .fcgi
  FcgidConnectTimeout 5
  FcgidIOTimeout 240
  FcgidMaxProcesses 1500
  FcgidMaxProcessesPerClass 150
  FcgidMinProcessesPerClass 10
  FcgidMaxRequestInMem 655360
  FcgidMaxRequestLen 1310720
  DefaultInitEnv LD_LIBRARY_PATH /usr/lib:/usr/lib
</IfModule>

Avant PostgreSQL 9.3 il était aussi nécessaire de bien configurer le système pour des performances sur la base de données.

Pour référence les paramètres du kernel sont par défaut sous dimensionnés, ils peuvent être déterminés simplement par un script repris depuis ce Blog.

vi shmsetup
chmod o+x shmsetup
#!/bin/bash
page_size=`getconf PAGE_SIZE`
phys_pages=`getconf _PHYS_PAGES`
shmall=`expr $phys_pages / 2`
shmmax=`expr $shmall \* $page_size`
echo kernel.shmmax = $shmmax
echo kernel.shmall = $shmall
./shmsetup >> /etc/sysctl.conf
sysctl -p

Epilogue

Vous devez probablement vous dire que tout ce que nous retirons du serveur est pour l’instant bien austère et manque cruellement de dynamisme par rapport à ce que l’on peut voir ailleurs et vous avez raison mais l’on a rien sans rien, et bientôt vous serez comblé. Dans le prochain article nous verrons comment faire appel à ces services sous un client Web

Charger des données spatiales : alimenter PostgreSQL PostGIS

Nous avons récupéré les données spatiales qui vont servir à développer l’application cartographique puis installé la base de données et son extension spatiale qui vont servir à enregistrer les données.

Quand se pose la question de l’alimentation de la base, une multitude de solutions se profilent. On peut par exemple utiliser un outil logiciel en ligne de commande, une extension à un logiciel existant, QGIS ou Pg admin proposent de telles extensions, ou encore utiliser un ETL (Extract Transform Load) comme Geokettle afin d’automatiser le processus.

Nos jeux de données possèdent quelques particularités comme nous l’avons vu et je vais couvrir deux cas de figure pour répondre au besoin.

Gérer les tables au travers de QGIS Desktop

QGIS est une solution complète qui permet d’effectuer beaucoup d’opérations simplement à la souris et avec beaucoup de contrôle par rapport à un outil en ligne de commande. Je vais l’utiliser en priorité avec l’extension DB Manager.

Sur les anciennes version de QGIS l’extension SPIT permettait déjà l’import de fichiers au format Shapefile vers une base de données. Cette extension sera remplacée à terme par un système unifié, DB Manager que nous utiliserons pour nos besoins. (J’ai rencontré par ailleurs des problèmes d’import avec SPIT sur les dernières versions)

Import des données et coordonnées géographiques des établissements scolaires français

Ouvrez QGIS et procédez à l’ouverture du fichier de données (référez vous au document de préparation des données).

Le logiciel écarte automatiquement 120 enregistrements pour absence de géométrie, nous n’avons pas à faire l’opération manuellement.

Vous devriez alors vous retrouver avec une représentation des établissements par des points pour les établissements de métropole et d’outre mer.

Comme nous l’avons vu précédemment, le fichier possède plusieurs système de coordonnées pour les valeurs X et Y, et seules les valeurs pour la France métropolitaine et la Corse sont exprimées dans le système de coordonnées RGF93 / Lambert-93, les points pour les établissements en dehors de la métropole sont donc mal positionnés sur la carte.

Nous avons également vu qu’au niveau des attributs seul le code postal est un facteur discriminant, nous ne possédons pas d’information sur le département ou la commune.

Voyons comment éliminer simplement les établissements d’outre mer pour lesquels nous ne possédons pas de données parcellaires sur les cultures agricoles.

La liste des codes département à l’outre mer est :

971 Guadeloupe
972 Martinique
973 Guyane
974 Réunion
975 St Pierre et Miquelon
976 Mayotte
986 Wallis et Futuna
987 Polynésie Française
988 Nouvelle Calédonie

Le code postal pour la France d’outre mer commence par les 3 chiffres du code départements, on peut s’arrêter aux 2 premiers chiffres pour éliminer l’outre mer, la recherche d’un numéro 97 ou 98 en première position du code postal permet de sélectionner un enregistrement à éliminer.

Ouvrez la table d’attributs (clic droit sur la couche dans l’explorateur de couches).

Par défaut le tableau fait afficher tous les enregistrements. QGIS permet de faire une sélection d’enregistrements par filtre et d’enregistrer la sélection comme une nouvelle couche. C’est ainsi que nous allons procéder, en enregistrant la sélection de tous les enregistrements dont le code postal ne reflète pas une appartenance à la France outre mer.

Ouvrez la table d’attributs (clic droit sur la couche dans l’explorateur de couches). En bas à gauche de l’explorateur sélectionnez l’entrée déroulante Filtre avancé (Expression)

QGIS ouvre une fenêtre qui permet de construire une expression qui va permettre de filtrer les enregistrements.

Dans la liste des fonctions, sélectionnez le champ code_postal_uai dans Champs et valeurs, puis l’opérateur LIKE dans Opérateurs, puis tapez le format de valeur ‘97%’ qui signifie que l’on souhaite une valeur qui débute par les caractères ‘9’ et ‘7’ suivis de n’importes quels caractères. Vous devriez être familier du format d’expression si vous connaissez SQL, QGIS utilise un format semblable avec un jeu d’instructions SQL limité pour les expressions. Il suffit de chercher les enregistrements dont le code postal commence par 97 ou 98 et inverser la condition de recherche.

Le format complet de l’expression est :

NOT (
 "code_postal_uai"  LIKE '97%'
 OR
 "code_postal_uai"  LIKE '98%'
)
Une fois la table filtrée, sélectionnez tous les enregistrements par un clic dans l’angle gauche de la table

 

Il reste à enregistrer ces informations comme une nouvelle couche. Vous pouvez au passage vérifier sur la carte : les points sélectionnés apparaissent en jaune.

Faites un clic droit sur la couche d’établissements dans l’explorateur de couches tout en conservant la table d’attributs ouverte, sélectionnez l’entrée de menu Sauvegarder sous

QGIS vous propose de sauvegarder la couche, nous pourrions profiter de l’occasion pour transformer la couche dans une autre projection et basculer l’encodage des attributs à UTF-8 mais ne le faites pas pour le moment.

Sélectionnez le format ESRI Shapefile, le SCR de la couche RGF93 / Lambert-93, le codage ISO-8859-1 et cochez la case qui permet de n’enregistrer que les entités sélectionnées et celle qui permet d’ajouter le fichier sauvegardé à la carte. Validez puis supprimez la couche d’établissements obsolète.
Notez que QGIS limite la taille des noms d’attributs à 10 caractères lors de la sauvegarde : c’est une limitation du format Shapefile où les noms d’attributs sont limités à 10 caractères, les noms d’attribut du fichier des établissements sont donc tronqués.

 

Si vous parcourez les attributs de la couche vous pouvez constater que l’import du fichier texte ne s’est pas déroulé comme l’on pouvait s’attendre, le programme a considéré le champ code postal comme une valeur numérique et a supprimé le zéro qui préfixe les codes postaux sur quatre chiffres ! Nous allons rétablir le code postal en tant qu’attribut de type texte, complété à gauche par des caractères zéro ‘0’ sur 5 caractères.

Ouvrez les propriétés de la couche sauvegardée d’un double clic sur son nom, et sélectionnez l’onglet Champs.Cliquez sur l’icône de crayon pour basculer en mode édition : vous pouvez alors accéder à l’icône de boulier qui permet de calculer un champ.Ouvrez le calculateur de champ.

Le calculateur de champ permet de créer de nouveaux champs à partir de fonctions ou de champs existants, ou de redéfinir des champs existants. Ce qui nous importe est de redéfinir le champ de code postal afin qu’il devienne de type chaîne de caractère et soit complété à droite.

Cochez la case de création d’un nouveau champ nommé code_post de type texte et de longueur 254.
Dans la liste des fonctions vous pouvez trouver les fonctions de chaîne de caractères et plus bas les champs et valeurs, ces fonctions permettent à nouveau de construire une expression.Saisissez l’expression :
lpad("code_posta" , 5, '0')
L’expression signifie que l’on créé un nouveau champ en appliquant la fonction de chaîne de caractères complémentée à gauche sur le champ code_posta (code_postal_uai du fichier d’origine mais tronqué à 10 caractères) paramétrée pour complémenter avec des caractères ‘0’ sur une longueur de 5 caractères au total.Sélectionnez la colonne code_posta obsolète et cliquez sur l’icône de suppression à gauche du bouton de passage en édition puis désactivez le mode édition et enregistrez lorsque vous y êtes invité.

Vérifiez la modification dans la table d’attributs.

Nous allons maintenant procéder à une jointure spatiale entre deux couches de données afin de palier au manque d’informations de nos données d’établissement.

Effectuer une jointure entre 2 couches sous QGIS

Pour faire une jointure nous allons nous baser sur la position géographique des établissements et les données des fichiers IGN pour les contours administratifs des départements et communes.

Ajoutez la couche vecteur des départements récupérée lors de la préparation des données. (Faites glisser le fichier DEPARTEMENT.SHP sur la fenêtre d’application QGIS)
Rendez vous dans le menu Vecteur > Outils de gestion de données > Joindre les attributs par localisation
Paramétrez la fenêtre :

  • Indiquez une couche vecteur : indiquez la couche des établissements sur laquelle va se faire la jointure
  • Joindre la couche vecteur : indiquez la couche des données à joindre, les départements
  • Résumé de l’attribut : indiquez de prendre les attributs de la première entité au cas où plusieurs entités sont concernées par la jointure
  • Saisissez un fichier Shapefile de résultat
  • Table en sortie : lorsque les entités de la couche de départ n’ont pas de correspondance dans la couche à joindre on conserve tout de même les enregistrements

 

Après vérification dans la table attributaire, l’opération a aboutit à l’ajout des attributs de la couche département aux attributs de la couche d’établissements sauf pour une vingtaine d’établissements en bord de littoral. Cette fois recommençons l’opération en supprimant les entités de la table en sortie afin d’éliminer ces cas que nous négligerons donc parmi environ 65000 établissements.

QGIS utilise un opérateur intersection pour la jointure spatiale, je n’ai pas testé le cas où plusieurs entités seraient concernées par la jointure

Procéder à l’import

Le moment est venu d’importer les données d’établissements et départements en base. L’extension DB Manager (installée par défaut) va nous permettre de transférer ces couches vers PostGIS.

La première chose à faire avant de lancer le gestionnaire de bases de données est d’établir une connexion à la base PostGIS.

Ouvrez la fenêtre de gestion des tables PostGIS, depuis le menu
Couche > Ajouter une couche PostGIS

La fenêtre qui s’ouvre permet d’ajouter une table spatiale récupérée depuis PostGIS, c’est également à partir de cet endroit que l’on peut créer une nouvelle connexion, l’extension DB Manager ne permet pas de le faire contre toute attente.

Paramétrez une nouvelle connexion comme sur l’exemple suivant.Donnez un nom parlant à la connexion : je donne l’adresse du serveur, le type PostGIS et le nom de la base de données pour les distinguer plus tard depuis le nom.

QGIS viens d’enregistrer une nouvelle connexion à la base. Vous pouvez ensuite quitter la fenêtre d’ajout de tables PostGIS, nous reviendrons plus tard après création des tables.

Sous QGIS, ouvrez le gestionnaire de base de données :
Base de donnée > Gestionnaire de base de données > Gestionnaire de base de données

Une fois que vous avez la connexion vous pouvez charger les données d’une couche ou d’un fichier à partir du gestionnaire de base de données.

Sélectionnez la connexion dans l’explorateur, sous l’entrée PostGIS, puis cliquez sur l’icône en forme de flèche vers la gauche, elle permet de paramétrer un import.

La couche des départements est dans le système de coordonnées de référence EPSG:2154 – RGF93 / Lambert-93, l’encodage est System. (Ces informations sont visibles dans les propriétés générales de la couche). Pour importer les données nous préciserons de convertir l’encodage en UTF-8 et de passer le SCR des données en WGS 84 d’identifiant EPSG:4326.

Ce dernier point n’est pas obligatoire mais je préfère unifier les système pour faciliter les développements ultérieurs et éviter les conversions en aval entre tables de données sous PostGIS.

Paramétrez l’import de la couche des départements :

  • Saisie : vous pouvez sélectionner une couche parmi les couches du projet ouvert ou bien utiliser le bouton de navigation à droite afin de préciser le chemin vers un fichier Shapefile.
    Sélectionnez la couche DEPARTEMENT
  • Saisissez le nom de la table Departement pour la table en sortie
  • Précisez le schéma : public
  • Dans l’encart Action vous pouvez si besoin écraser la table existante
  • Précisez la clef primaire : id_geofla. Ici on réutilise l’attribut qui sert d’identifiant dans la couche, par défaut le gestionnaire créé un identifiant associé à une séquence si ce champ n’est pas renseigné
  • Précisez si vous le souhaitez la colonne de géométrie the_geom. Certaines conventions utilisent the_geom, depuis les dernières versions de PostGIS geom est utilisé comme nom de colonne par défaut
  • Précisez le SCR source : 2154
  • Précisez le SCR cible : 4326
  • Précisez le codage cible : UTF-8
  • Cochez la case afin de créer un index spatial

Une fois l’import réussi il est possible de réactualiser la liste des objets de la connexion afin de faire apparaître la table qui vient d’être crée. Les onglets du gestionnaire sur la droite permettent d’accéder aux informations sur la table, de vérifier le contenu et le rendu.

Le gestionnaire donne également accès à certaines vues, si l’on consulte la vue geometry_columns on vérifie bien la présence d’une nouvelle ligne où l’on accède aux informations sur la colonne de type Geometry que l’on a créé dans la nouvelle table. Vous pouvez à nouveau vous référer aux documents de standards sur la vue geometry_columns. La vue référence bien la colonne geom de la table Departement, de type MULTIPOLYGON et de srid 4326.

Ultérieurement j’ai rencontré des problèmes avec des requêtes spatiales sur cette table du fait que la colonne est de type MULTIPOLYGON, mais que certaines de ses entrées sont de type GeometryCollection. Une solution serait peut être de modifier dans les fichiers SQL générés les instructions de création de table. J’ai choisi par manque de temps d’ignorer (grâce à la fonction ST_IsValid()) les lignes de type GeometryCollection qui représentent un pourcentage négligeable.

Si vous observez maintenant la table sous PgAdmin vous pouvez vérifier le contenu de la colonne de géométrie sur quelques enregistrements : le contenu est au format standard WKB (Well-Known Binary), un format de représentation hexadécimal. A présent vous pouvez parcourir les fonctions spatiales de PostGIS dans l’explorateur d’objet de PgAdmin, la fonction st_astext(geometry) permet de retourner la représentation de la colonne dans le format standard WKT (Well-Known Text) que l’on utilisera ultérieurement pour les échanges d’informations entre les différents composants de l’application cartographique que l’on va mettre en place.

Un exemple de requête retournera une représentation WKT où l’on va voir apparaître des objets de type MULTIPOLYGON, le type de la colonne, avec des coordonnées en 2 dimensions (X et Y) exprimées dans le SCR de référence SRID 4326 tel que l’on peut le consulter dans la définition de la colonne dans la vue geometry_columns :

SELECT st_astext(geom) FROM "Departement" LIMIT 1;
"MULTIPOLYGON(((5.83122641362104 45.9384595782932,5.82212102507634 45.9301359535238,5.82915456688181 45.9172371235251,5.82614026745121 45.903273829068,5.81515407306268 45.8772007832312,5.80752504239684 45.8606398467483,5.80110949497983 45.8457964776072,5.79 (...)"

Procédez à l’import de la couche des établissements via DB Manager, nommez la table Etablissement et utilisez l’attribut numero_uai comme clef primaire.(Mon installation sur VPS à ressources limitées ne supporte pas le chargement de telles quantités de données au travers de la connexion au serveur de base de données. Pour les développements je travaillerai en local pour le confort d’utilisation et j’utiliserai l’utilitaire de sauvegarde et restauration pour migrer la base afin de faire fonctionner le prototype)

Attention à l’encodage des caractères… malgré les options dans DB Manager j’ai dû au préalable changer le jeu de caractères en UTF-8 en sauvegardant la couche sous un autre nom (on peut préciser le jeu de caractères lors de l’enregistrement)

Pour gagner du temps je n’ai pas supprimé d’attributs inutiles ni renommé les noms tronqués des colonnes… mais rien ne vous empêche de le faire. Lors de la sauvegarde d’une couche on peut renommer les attributs, sinon après import il suffit de passer par PgAdmin et d’effectuer un clic droit sur la table dans l’explorateur d’objets pour faire afficher les propriétés. On accède alors à la définition des colonnes dans un onglet, où l’on peut ajouter, modifier ou supprimer une colonne.

Import des données de parcelles

Traiter les parcelles une à une serait assez consommateur de temps.

Nous disposons d’une centaine de ressources sous format d’archive zip, d’en moyenne 15M, ce qui totalise environ 1,5Go.

Je fais le choix de conserver le découpage par départements plutôt que d’utiliser une table unique où l’on ajouterai les données de chaque département. Cela permettra d’interroger individuellement les tables en fonction des codes département.

Deux options se présentent alors pour interroger la France métropolitaine dans son intégralité : dans un premier temps j’utiliserai une vue, puis je couvrirai les capacités de partitionnement de tables offertes par PostgreSQL avec héritage d’une table maître pour comparer les approches.

Procéder à l’import

L’import doit se dérouler de manière automatique, on doit pour cela utiliser un script combiné à un utilitaire d’import en ligne de commande.

Pour le langage de script j’utiliserai Python qui est notamment employé sous QGIS.

Vous pouvez trouver une version embarquée sous Windows sous le répertoire d’installation de QGIS :

cd C:\Program Files\QGIS Chugiak\bin
python --version
Python 2.7.4

Pour l’utilitaire en ligne de commande j’utiliserai shp2pgsql pour la génération de fichiers SQL couplé à psql pour le chargement en base, il sont disponibles dans le répertoire d’installation de PostgreSQL :
C:\Program Files\PostgreSQL\9.3\bin

Ajouter le chemin vers ces exécutables à la variable système PATH permet de s’affranchir des chemins lorsque l’on lance une commande.

Import à l’aide de shp2pgsql

shp2pgsql convertit un fichier Shapefile en instructions SQL qui peuvent être exploitées ensuite pour alimenter une base de donnée, par psql dans notre exemple.

L’utilitaire peut être accompagné d’une interface utilisateur qui permet également le chargement (ou l’export) de plusieurs fichiers. Elle offre cependant moins de contrôle et je ne couvrirai pas son utilisation mais elle peut s’avérer pratique lorsque l’on a un nombre restreint de fichiers à importer.

Sous Windows l’utilitaire est disponible sous le répertoire d’installation de PostgreSQL :

C:\Program Files\PostgreSQL\9.3\bin\postgisgui\shp2pgsql-gui.exe

La sortie de commande de shp2pgsql peut être capturée dans un fichier SQL (ou redirigée vers un autre utilitaire sous système de type Unix)

Les paramètres de commande sont :

shp2pgsql [<options>] <shapefile> [[<schema>.]<table>]

L’ouverture d’un fichier *.shp sous QGIS nous renseigne sur le SCR et l’encodage. Un coup d’œil aux attributs permet d’ignorer les problématiques d’encodage puisque l’on travaille avec des valeurs numériques.

Au niveau des options dans notre cas de figure :

  • -s [:] Fixe le SRID en entrée et en sortie
  • -c Créé une nouvelle table et les instructions d’insertion
  • -I Créé un index spatial sur la colonne géométrie
  • -N skip Les enregistrements avec géométries vides ne seront pas importés
Si vous ne l’avez pas déjà fait, décompressez les archives de parcelles dans un répertoire !

L’usage est alors le suivant :

(Sous Windows 7 la combinaison touche SHIFT + Clic droit sur la fenêtre d’explorateur en cours permet d’ouvrir une fenêtre de commande à cet endroit, vous pouvez sinon ouvrir un terminal à l’aide de l’utilitaire cmd)

cd C:\SIG\data\parcelles\RPG_2012_004
shp2pgsql -s 2154:4326 -c -I -N skip RPG_2012_004.shp Cultures_004 > "C:\SIG\sql\Cultures_004.sql"

Le fichier SQL généré contient dans une transaction les instructions de création de table, d’indexe et de clef primaire, ainsi que les instructions d’insertion des données. La colonne géométrie est ajoutée après création de table pour être compatible avec les anciennes versions de PostGIS. Nous pourrons plus tard altérer ces instructions SQL pour tester l’implémentation avec héritage d’une table maître.

SET CLIENT_ENCODING TO UTF8;
SET STANDARD_CONFORMING_STRINGS TO ON;
BEGIN;
CREATE TABLE "cultures_004" (gid serial,
"num_ilot" varchar(12),
"cult_maj" int4);
ALTER TABLE "cultures_004" ADD PRIMARY KEY (gid);
SELECT AddGeometryColumn('','cultures_004','geom','4326','MULTIPOLYGON',2);
INSERT INTO "cultures_004" ("num_ilot","cult_maj",geom) VALUES ('004-190399','4',ST_Transform('01060000206A0800000100000001030000000100000015000000787AA5EC64362C41C9E53F4C6D195841A8A44E8015362C41BC7493087E1958417DAEB622F9352C4106F016707D195841151DC92599352C410DE02D509E195841F241CFA696352C41F697DD03A01958416519E2189C352C41D2DEE0DBA119584183C0CAE1B1352C411A2FDDE4A4195841E10B9369BB352C41EA73B565A6195841151DC965C5352C41840D4FB7A9195841AACFD556CD352C41F1F44A19AC1958416D348037E2352C4189B0E139AE19584182E2C71812362C4147E17AE4B0195841C56D34405F362C41627FD9EDA7195841499D80E673362C415E29CB20A4195841B515FB4B7C362C41D634EF20A01958416132557078362C41779CA2338E19584179C729FA7C362C41D1915C7E85195841174850BC86362C41E92631F87E195841F163CCDDA7362C41F54A590E73195841728A8EA487362C41627FD99D70195841787AA5EC64362C41C9E53F4C6D195841'::geometry, 4326));
...
CREATE INDEX "cultures_004_geom_gist" ON "cultures_004" USING GIST ("geom");
COMMIT;

Il reste à exécuter le script sur la base de données. Nous pouvons utiliser l’utilitaire psql.

psql --host=localhost --port=5432 --username=postgres --no-password --dbname=Pesticides --file="C:\SIG\sql\Cultures_004.sql"

Les options sont :

  • –host=HOTE nom d’hôte du serveur de la base de données ou répertoire de la socket (par défaut : socket locale)
  • –port=PORT port du serveur de la base de données (par défaut : « 5432 »)
  • –username=NOM nom d’utilisateur de la base de données
  • –no-password ne demande jamais un mot de passe
  • –dbname=NOM_BASE indique le nom de la base de données à laquelle se connecter
  • –file=FICHIER exécute les commandes du fichier, puis quitte

Vous pouvez vérifier l’import sous PgAdmin et supprimer la table en cascade.

Nous savons comment réaliser un import, voyons comment réaliser un traitement par lot.

Import automatisé, traitement par lot à partir d’un script

Un script très simple va permettre d’automatiser l’appel à l’utilitaire shp2pgsql avec les paramètres que nous avons validés précédemment.

Ajoutez si ce n’est pas déjà fait le chemin vers l’exécutable python à la variable système PATH

Enregistrez le script suivant dans un fichier d’extension .py placé sous le répertoire où se trouvent vos données, le script utilise le répertoire courant pour rechercher les fichiers Shapefile.

J’ai pris soin d’écrire du code portable mais vous devrez peut être procéder à des adaptations sous votre système d’exploitation.

#! /usr/bin/python
import os
import fnmatch

instructions = r'shp2pgsql -s 2154:4326 -c -I -N skip "%s" %s > "%s"'

currentdir = os.path.realpath(os.curdir)
sqldirectory = currentdir + os.sep + 'sql'
if not os.path.exists(sqldirectory):
    os.makedirs(sqldirectory)

for root, dirnames, filenames in os.walk(currentdir):
  for shapefilename in fnmatch.filter(filenames, '*.shp'):
      print shapefilename, '...'
      shapefilepath = root + os.sep + shapefilename
      tablename = 'Cultures_' + shapefilename[-6:-4]
      sqlfilepath = sqldirectory + os.sep + tablename + '.sql'
      command = instructions % (shapefilepath, tablename, sqlfilepath)
      os.system(command)

raw_input("Appuyez sur une la touche Entree pour quitter...")

Que fait le programme ?

Premièrement il créé une variable où l’on conserve un gabarit d’appel générique à la commande shp2pgsql, le nom du fichier shapefile, le nom de table et le fichier de destination sont des paramètres.

Ensuite il récupère le répertoire courant et créé si besoin un répertoire sql sous le répertoire courant.

Le programme parcours alors le répertoire courant et les sous répertoires à la recherche de fichiers d’extension .shp, lorsqu’il trouve un fichier il extrait le code du département et construit les paramètres (chemin vers le fichier, nom de table, chemin vers le fichier de destination) et appelle la commande ainsi formatée en exécution système.

Nos fichiers sql sont alors disponibles sous le répertoire sql dans le répertoire courant, il reste à les modifier à nos besoins et à les importer.

Ce deuxième script est très semblable au premier. Dans ce cas on recherche les fichiers d’extension .sql sous les dossiers du répertoire courant et lorsqu’ils sont trouvés la commande système d’appel à psql est lancée afin d’exécuter les instructions SQL contenues dans le fichier.

#! /usr/bin/python
import os
import fnmatch

instructions = r'psql --host=localhost --port=5432 --username=postgres --no-password --dbname=Pesticides --file="%s"'

for root, dirnames, filenames in os.walk(os.path.realpath(os.curdir)):
  for sqlfilename in fnmatch.filter(filenames, '*.sql'):
      print sqlfilename, '...'
      sqlfilepath = root + os.sep + sqlfilename
      command = instructions % (sqlfilepath)
      os.system(command)

raw_input("Appuyez sur une la touche Entree pour quitter...")

L’opération prend un certain temps, même en local, mais le chargement en base c’est bien déroulé.

A présent créons une vue qui regroupera l’information de toutes les tables, ou plutôt une vue matérialisée.

Une vue matérialisée, à la différence d’une vue, stocke à la fois la requête qui a permis la génération de la vue mais également les données du résultat de requête. La vue matérialisée est équivalente sur ce point à une table et il est possible de l’indexer de la même façon. Ce comportement diffère de la vue qui exécute la requête à chaque appel, ce qui dans notre cas se traduirait par des performances moindres, dans le sens où les indexes de chaque table ne seraient probablement pas utilisés au mieux.

L’inconvénient principal des vues matérialisées c’est qu’il faut rafraîchir la vue lorsque les tables impactées par la requêtes sont modifiées, ce qui ne pose pas problème par rapport à nos données qui sont des données destinées à la consultation uniquement, pas à la mise à jour. Par contre les données sont dupliquées, nous aurons donc environ 1,5Go de données en double.

Lors de la création des instruction SQL, shp2pgsql créé une colonne gid qui sert de clef primaire. Cette colonne est construite sur la base d’une séquence à partir de 1, incrémentée de 1, ce qui est problématique pour lier plusieurs tables dans une vue : nous ne devons pas utiliser l’attribut gid dans la vue, ce qui créerait des doublons de clef, mais préciser lors du chargement d’une couche sous QGIS que l’attribut de référence pour la clef est le numéro d’îlot num_ilot

Nous devons aboutir à une instruction SQL comme celle ci :

CREATE MATERIALIZED VIEW mv_cultures AS
SELECT num_ilot, cult_maj, geom FROM "cultures_01" UNION ALL
SELECT num_ilot, cult_maj, geom FROM "cultures_02" UNION ALL
(...)
SELECT num_ilot, cult_maj, geom FROM "cultures_95"

Nous possédons tous les codes département dans la table Departement, pour générer la vue il va falloir créer la requête par une instruction SQL qui récupère tous les codes département sauf les Hauts de seine et Paris pour lesquels il n’existe pas de table, puis exécuter la requête :

SELECT
 'CREATE MATERIALIZED VIEW mv_cultures AS ' ||
 string_agg(format('SELECT num_ilot, cult_maj, geom FROM "cultures_%s"', lower("code_dept")), ' UNION ALL ')
FROM
 "Departement"
WHERE
 code_dept NOT IN ('75', '92');

Vous pouvez enregistrer la requête dans un fichier et passer par psql pour générer le résultat et vérifier avant exécution.

psql --host=localhost --port=5432 --username=postgres --no-password --dbname=Pesticides --file="C:\SIG\sql\createquery.sql" --quiet --output="C:\SIG\sql\query.sql" --log-file="C:\SIG\sql\createquery.log"
psql --host=localhost --port=5432 --username=postgres --no-password --dbname=Pesticides --file="C:\SIG\sql\query.sql"

Procédez de même avec une instruction CREATE VIEW et une instruction CREATE TABLE afin de comparer les plans d’exécutions de requêtes de type SELECT.N’oubliez pas de créer clefs primaires et indexes sur la vue matérialisée et la table…

CREATE INDEX t_cultures_geom_gist ON t_cultures USING gist (geom);
ALTER TABLE t_cultures ADD PRIMARY KEY (num_ilot);

CREATE INDEX mv_cultures_geom_gist ON mv_cultures USING gist (geom);
CREATE UNIQUE INDEX ON mv_cultures (num_ilot);
Partitionnement de la table

Une autre option qui s’offre à nous est de créer une table logique à partir de plusieurs tables physiques plus petites.

PostgreSQL supporte une forme simple de partitionnement de table. Je vais l’essayer sur ce cas de figure pour le découpage départemental des données et comparer le plan d’exécution avec les autres approches.

Le fichier de configuration de PostgreSQL permet de contrôler de quelle façon le planificateur de requêtes utilise les contraintes de table pour optimiser les requêtes.

Pour modifier le paramètre d’exclusions de contraintes, il suffit d’éditer le fichier postgresql.conf (sous C:\Program Files\PostgreSQL\9.3\data) :

constraint_exclusion = partition	# on, off, or partition

Le paramètre par défaut est partition, il pourra être intéressant de modifier le paramètre pour vérifier l’impact sur les plans d’exécution.

Procédons à la création de la table logique qui sera la table maître dont les autres tables vont hériter. La table ne possède pas d’indexe, et par rapport à la définition des tables cultures, elle possède désormais un code département.

CREATE TABLE master_cultures
(
  num_ilot character varying(12),
  cult_maj integer,
  code_dept character varying(2),
  geom geometry(MultiPolygon,4326)
)
;

Il reste ensuite à créer chaque table fille avec les instructions d’héritage appropriées. Les requêtes sont construites à la volée comme précédemment, le code n’est pas très élégant mais sera à usage unique !

SELECT
 string_agg(
	 format('DROP TABLE IF EXISTS child_cultures_%s;CREATE TABLE child_cultures_%s AS SELECT num_ilot, cult_maj, (SELECT CODE_DEPT from "Departement" WHERE code_dept = ''%s''), geom FROM "cultures_%s";ALTER TABLE child_cultures_%s ADD CONSTRAINT CK_%s CHECK (code_dept = ''%s'');ALTER TABLE child_cultures_%s ADD CONSTRAINT PK_%s PRIMARY KEY (num_ilot);CREATE INDEX child_cultures_%s_geom_gist ON child_cultures_%s USING gist (geom);ALTER TABLE child_cultures_%s INHERIT master_cultures;'
	 , lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept")
	 , lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept"), lower("code_dept")
 ), ' ; ')
FROM
 "Departement"
WHERE
  code_dept NOT IN ('75', '92');

Ces données ne sont pas destinées à être mises à jour, il n’est pas nécessaire de contrôler les opérations de mise à jour sur la table maître par des procédures stockées comme dans la documentation. (La table ne possède d’ailleurs pas de clef primaire ni d’OIDS, l’accès en édition sera interdit sous PgAdmin)

Tout ça pour ça… Et maintenant ? Contrôlons la validité de chaque vue et chaque table créées pour un usage en tant que couche vectorielle.

Contrôle du chargement des couches PostGIS sous QGIS

Afin de vérifier le chargement d’une couche PostGIS à partir de la vue, de la vue matérialisée ou de la table il faut au préalable définir une emprise pour le rendu qui soit suffisamment restreinte pour ne pas charger toutes les données depuis PostGIS sans quoi il va falloir être à nouveau très patients !

Depuis la couche GEO_FLA des communes j’ai effectué sous QGIS un zoom sur Montélimar.

Cela se fait simplement depuis la table d’attributs en sélectionnant une ligne puis en cliquant sur l’icône de zoom sur la ligne sélectionnée.

La barre de statuts en bas permet d’afficher l’emprise (icône souris à côté des coordonnées), elle s’exprime sous la forme xmin,ymin : xmax,ymax où les coordonnées sont exprimées dans le SCR du projet :

xmin,ymin : xmax,ymax EPSG:4326
4.689,44.509 : 4.803,44.601
Sous QGIS, zoomez sur une commune pour restreindre les limites d’affichage puis allez dans le menu :
Couche > Ajouter une couche PostGISSous la connexion à la base vous devez apercevoir les vues et table nouvellement crées.

Avant d’ajouter une vue, vous devez préciser à QGIS la clef primaire, le numéro d’îlot, sans quoi QGIS retournera une erreur d’invalidité de la couche.

L’ajout de la couche à partir de la table ou de la vue matérialisée est presque instantané, alors que pour la vue… eh bien, vous vous féliciterez peut être d’avoir investi dans un disque SSD dernier cri.

A l’œil nu, le rafraîchissement des polygones sur la carte prend sensiblement le même temps pour la table et la vue matérialisée lorsque l’on agrandit l’étendue représentée, il est à nouveau beaucoup plus lent en ce qui concerne la vue.

A partir de la table maître et des tables héritées l’ajout d’une couche n’est pas instantané, une douzaine de secondes sont nécessaires pour initialiser le rendu par contre le rafraîchissement est ensuite beaucoup plus réactif que pour la table et la vue matérialisée lorsque l’on représente une plus grande étendue.

Cherchons à savoir pourquoi les performances sont mauvaises avec une simple vue, quelles optimisations apporter aux autres cas et quel choix faire pour notre application.

Bilan à l’heure du choix. Explication du plan d’exécution

PostgreSQL permet de visualiser les plans d’exécution de chaque requête SQL, l’instruction EXPLAIN retourne des informations pertinentes sur le plan d’exécution qu’il va utiliser.

Pour tester les plans d’exécution dans chaque configuration je vais construire une requête spatiale afin de simplement retourner toutes les parcelles sur une étendue géographique donnée.

En effet, ce sera le mode de fonctionnement de l’application web, il importe de le tester : les librairies javascript en charge du rendu des cartes vont faire appel aux informations sur l’étendue de la carte en cours, avec possibilité d’agrandir ou rétrécir l’étendue représentée.

Lorsque l’étendue sera élevée la couche des parcelles ne sera simplement pas affichée, car elle n’apporte pas d’information particulière dans un contexte de rendu global, par contre elle sera visible dès que l’échelle de la carte sera suffisamment élevée.

Sous PostGIS, la fonction ST_MakeEnvelope créé un polygone rectangulaire à partir de coordonnées X, Y et d’un SRID.

Le format est le suivant :

geometry ST_MakeEnvelope(double precision xmin, double precision ymin, double precision xmax, double precision ymax, integer srid=unknown);

Pour la commune de CAHUZAC située dans le département de code 47, le polygone recouvrant l’étendue géographique sera donc obtenu par :

SELECT ST_AsText(ST_MakeEnvelope(0.533, 44.639, 0.589, 44.685, 4326));

L’option ANALYSE permettra de lancer l’exécution de la requête afin de disposer des temps d’exécution et nombre de lignes réels.

L’étendue géographique sera celle de l’Auvergne :

EXPLAIN ANALYSE SELECT ST_AsText(geom) FROM t_cultures WHERE geom && ST_MakeEnvelope(2, 45 , 5, 47, 4326);
EXPLAIN ANALYSE SELECT ST_AsText(geom) FROM mv_cultures WHERE geom && ST_MakeEnvelope(2, 45 , 5, 47, 4326);
EXPLAIN ANALYSE SELECT ST_AsText(geom) FROM v_cultures WHERE geom && ST_MakeEnvelope(2, 45 , 5, 47, 4326);
EXPLAIN ANALYSE SELECT ST_AsText(geom) FROM master_cultures WHERE geom && ST_MakeEnvelope(2, 45 , 5, 47, 4326);

Les résultats sur cette requête me laissent perplexe car ils semblent contradictoires avec le ressenti sous QGIS.

La méthode est certainement sujette à un problème, si quelqu’un peut m’éclairer les remarques seront bienvenues pour construire des requêtes de test plus pertinentes !

Ce article est assez long, et je m’égare. La prochaine étape sera d’installer un serveur cartographique. L’héritage de table donne des résultats corrects même si la vue semble meilleure, par rapport au ressenti sous QGIS c’est la solution que je vais retenir par défaut, je conserve toutefois la possibilité de basculer sur les autres solutions.

Installation d’une base de données spatiales PostgreSQL PostGIS

PostgreSQL PostGIS

Introduction

PostgreSQL est un système de gestion de bases de données relationnelles Open Source. Il est reconnu pour sa fiabilité et ses performances. PostGIS est un module de PostgreSQL qui apporte un lot de capacités et de fonctions spatiales à la base. Il respecte les standards internationaux et il est devenu une référence pour stocker, gérer et analyser des données spatiales. PostGIS est supporté par la fondation OSGeo.

PostGIS implémente les spécifications SQL pour l’information géographique OpenGIS® définies par l’Open Geospatial Consortium (OGC), un consortium international qui établie les documents techniques standards OGC® Standards. Ce document technique est une source d’informations qui peut servir même sans rentrer dans une étude complète et ennuyeuse du standard !

Vous pouvez suivre le tutoriel d’introduction à PostGIS proposé en anglais par OpenGeo. La documentation PostGIS propose des exemples standards des fonctions SQL, gardez là également sous la main !

Installation sous Debian « Wheezy »

Une ligne de commande suffit sous Debian pour installer le serveur de base de données et l’extension spatiale cependant nous allons au préalable ajouter un dépôt au gestionnaire de paquets de la distribution Debian Wheezy afin de récupérer les dernières versions de Postgresql et PostGIS.

Éditez le fichier de sources du gestionnaire de paquets
vi /etc/apt/sources.list.d/pgdg.list
Insérez la ligne suivante :
deb http://apt.postgresql.org/pub/repos/apt/ wheezy-pgdg main
Récupérez la clef de sécurité et mettez à jour les informations dans le gestionnaire de paquets
wget https://www.postgresql.org/media/keys/ACCC4CF8.asc
apt-key add ACCC4CF8.asc
apt-get update
Procédez à l’installation
apt-get install postgresql postgresql-contrib libgdal-dev postgresql-9.3-postgis-2.1

Vous pouvez vérifier le numéro de version précisément installée.

psql --version
psql (PostgreSQL) 9.3.5

Lors de l’installation le système a créé un utilisateur système postgres, le client postgresql psql que nous venons d’utiliser est disponible dans le chemin des exécutables, et un super utilisateur nommé postgres, l’utilisateur par défaut des bases de données a été créé dans PostgreSQL.

Nous allons modifier le mot de passe de l’utilisateur base de données postgres. Notez le mot de passe, nous en aurons besoin ultérieurement.

su - postgres
psql
postgres=# \password postgres
Saisissez le nouveau mot de passe :
Saisissez-le à  nouveau :
postgres=# \q

Il s’agit de procéder rapidement à l’installation d’un serveur privé suffisant pour développer une application, je vais ignorer les aspects sécurité et performance d’un environnement de production, c’est à dire ne pas créer de rôles et d’utilisateurs de base de données et ne pas paramétrer le serveur au delà du strict minimum nécessaire à mon propre accès.

Dans l’immédiat ce qui m’importe est de pouvoir me connecter localement depuis l’outil en ligne de commande et depuis une application d’administration client distante avec, pour sécuriser l’accès, une restriction sur mon adresse IP et une connexion par mot de passe crypté.

PostgreSQL est paramétré par défaut pour refuser les connexions entrantes à partir des machines externes, ce qui implique que l’on ne peut pas se connecter depuis un outil d’administration sur un poste de travail.

Pour rendre l’accès disponible il faut paramétrer PostgreSQL pour écouter les connexions entrantes.

Paramétrez un accès distant :
su - postgres
vi /etc/postgresql/9.3/main/postgresql.conf
Modifiez le fichier pour écouter les connexions sur la boucle locale et sur l’adresse IP publique du serveur où est hébergé PostgreSQL.
#------------------------------------------------------------------------------
# CONNECTIONS AND AUTHENTICATION
#------------------------------------------------------------------------------

# - Connection Settings -

listen_addresses = 'localhost, 92.222.27.205'           # what IP address(es) to listen on;
                                        # comma-separated list of addresses;
                                        # defaults to 'localhost', '*' = all
                                        # (change requires restart)
port = 5432

Vous pouvez vérifier la prise en compte de vos modifications en lançant la commande netstat avant et après voir redémarré le serveur

exit
service postgresql restart
netstat -an | grep 5432
tcp        0      0 92.222.27.205:5432      0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:5432          0.0.0.0:*               LISTEN
tcp6       0      0 ::1:5432                :::*                    LISTEN
...

Maintenant que PostgreSQL écoute les connexions depuis l’extérieur, il est nécessaire d’étendre les règles de contrôle d’accès par défaut pour accepter les connexions.

Ajoutez une règle d’accès au fichier pg_hba.conf qui autorisera de vous connecter avec mot de passe depuis l’adresse IP de votre poste de travail :
su - postgres
vi /etc/postgresql/9.3/main/pg_hba.conf
Ajoutez la ligne suivante, l’information host permet d’indiquer que la tentative de connexion doit être de type TCP/IP, la première information all spécifie que toutes les bases de données sont acceptées, la seconde information all permet d’accepter tous les noms d’utilisateur de la base de données, enfin la plage d’adresses permet de spécifier les machines client acceptées, saisissez votre adresse IP (l’hôte), suivie du masque CIDR /32 (255.255.255.255) afin d’accepter une seule machine hôte, le dernier paramètre md5 indique la méthode d’authentification, vous devrez vous connecter par mot de passe crypté md5 :
host all all 85.171.50.88/32 md5

Après avoir redémarré le serveur nous allons voir comment nous connecter depuis une application cliente installée sur notre poste de travail.

exit
service postgresql restart

Outil d’administration Pg Admin III

Pg Admin est un outil d’administration et de gestion pour PostgreSQL. C’est un logiciel libre disponible sous Windows, Mac, Linux. Nous utiliserons Pg Admin en priorité par rapport à l’outil en ligne de commande.

Enregistrer une nouvelle connexion

Téléchargez et installez Pg Admin III, sélectionnez dans l’entrée du menu principal
Fichier > Ajoutez un serveur

Une fenêtre s’ouvre ou vous devez paramétrer la connexion, saisissez le nom sous lequel sera enregistré la connexion, le nom d’hôte du serveur sur lequel tourne PostgreSQL, le nom d’utilisateur postgres et le mot de passe que vous avez renseigné après installation du serveur.

Félicitations, vous pouvez désormais vous connecter et utiliser le navigateur d’objet à gauche pour obtenir des informations et interagir avec PostgreSQL.

Créer une base de données et activer l’extension spatiale

Nous allons passer par Pg Admin pour la plupart des opérations d’administration de bases de données. Ces opérations pourraient très bien être réalisées en SQL sous l’outil de ligne de commande psql : Pg Admin vous donne accès au code SQL qu’il génère.

Dans l’explorateur d’objets, faites un clic droit sur l’objet Bases de données et sélectionnez l’entrée Ajouter une base de donnée dans le menu contextuel qui vient de s’ouvrir.Remplissez le formulaire d’ajout de base de données :

  • Dans l’onglet Propriétés donnez pour nom Pesticides, et pour propriétaire postgres
  • Dans l’onglet Définition sélectionnez le codage UTF8, la collation et le type de caractères fr_FR.UTF-8
  • Dans l’onglet SQL vous pouvez vérifier l’instruction SQL qui sera exécutée et au besoin décocher le mode lecture seule pour passer en édition

L’outil exécute la commande SQL sur la connexion serveur active :

CREATE DATABASE "Pesticides"
  WITH ENCODING='UTF8'
       OWNER=postgres
       LC_COLLATE='fr_FR.UTF-8'
       LC_CTYPE='fr_FR.UTF-8'
       CONNECTION LIMIT=-1;
Vous pouvez altérer certaines propriétés de la base ultérieurement d’un double clic sur l’écran de propriétés qui apparaît à la sélection de la base de données dans le navigateur d’objet mais prenez soin de bien définir à la création les propriétés relatives au jeu de caractères et à la localisation

Vous venez de créer une base de données, il vous reste à activer le module spatial pour cette base.

Dans l’explorateur d’objets, faites un clic droit sur l’objet base de données Pesticides et sélectionnez l’entrée Ajouter un objet > Ajouter une extension dans le menu contextuel qui vient de s’ouvrir.Sélectionnez le nom postgis

L’outil exécute la commande SQL d’ajout d’une extension à la base de données :

CREATE EXTENSION postgis;

L’activation de l’extension permet désormais d’utiliser les fonctions spatiales.

Remarquez la création d’une table spatial_ref_sys sous le schéma public (par défaut si vous n’avez pas modifié le schéma). Vous pouvez parcourir les 100 premières lignes à l’aide d’un clic droit sur la table dans l’explorateur d’objet, sélectionnez l’entrée de menu contextuel Afficher les données > Visualiser les 100 premières lignes.

Vous pouvez vérifier dans la documentation du standard OpenGIS® les informations relatives à cette table : elle sert à décrire le système de coordonnées et les transformations pour la représentation de la géométrie. Elle permet de conserver en base les informations sur chaque système spatial de référence :

  • SRID est l’identifiant du système spatial de référence, il est donc unique et sert de clef dans la table
  • AUTH_NAME est le nom de l’autorité qui enregistre le système spatial, typiquement EPSG (European Petroleum Survey Group) pour le jeu de données par défaut. (voir http://spatialreference.org/ pour d’autres systèmes)
  • AUTH_SRID est l’identifiant du système de référence au sein de l’autorité qui l’enregistre (le code de projection EPSG dans le cas d’EPSG)
  • SRTEXT est la description du système spatial de référence au format Well known text (WKT est un format texte standard pour représenter les objets géométriques et les informations rattachées)
  • La dernière colonne PROJ4TEXT n’est pas dans le standard. PostGIS utilise la librairie de projections cartographiques Proj4 pour les transformations de coordonnées, la colonne contient les définitions de coordonnées Proj4 pour le SRID
A retenir : La table spatial_ref_sys contient les informations nécessaires pour permettre d’identifier les systèmes spatiaux de référence (SRS) et permettre de transformer et changer de projection d’un système à un autre.

Créer une table spatiale se résume à ajouter une colonne de type Geometry à la définition de table où l’on va préciser certaines informations en plus des informations de colonne par défaut :

  • Le SRID du système spatial de référence qui servira de clef étrangère vers la table spatial_ref_sys
  • La dimension spatiale COORD_DIMENSION : indique un entier qui correspond à 2, 3 ou 4 dimensions pour les coordonnées
  • Le type de l’objet géométrique qui doit être enregistré si l’objet possède un type unique (POINT, LINESTRING, POLYGON, MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION, …) ou bien le type GEOMETRY

Nous n’allons pas créer manuellement les tables spatiales mais munis de ces informations nous pouvons passer au chargement de données et contrôler le format des tables crées.