La technologie spatiale au service de JHipster

Un mode de coordonnées

Les puces GPS sont maintenant partout. Dans les voitures, dans les téléphones, même dans les montres… Nous sommes donc en permanence capables de nous géolocaliser et donc potentiellement d’interagir plus efficacement avec notre environnement. Cela a d’ores et déjà ouvert la voie à des usages qu’on n’aurait pas osé imaginer il y a quelques années, servis par des applications résolument innovantes, exploitant le plus finement possible les masses d’informations à disposition.

Gérer applicativement et efficacement des coordonnées géographiques n’a cependant rien de trivial. On est vite confronté à des problématiques de référentiel géographique ou de projection cartographique demandant quelques compétences en Système d’Information Géographique (SIG). Heureusement, on peut généralement s’en sortir avec quelques approximations souvent sans conséquence surtout en se limitant à certaines portions du globe. Et rapidement WGS84 et formule de Haversine n’auront plus de secret pour vous !

Les bases de données géographiques

Gérer des données géographiques en termes de persistance sans s’appuyer sur des types spécifiques ne peut conduire qu’à une impasse. En effet, l'interrogation de ces données ne prend un sens que si des notions de topologie sont possibles au niveau des opérateurs de requêtage.

Le SQL standard dispose d’un nombre assez restreint de types de données. Heureusement, les principaux systèmes de gestion de bases de données proposent des extensions ‘spatiales’, introduisant l’outillage nécessaire pour réaliser de véritables requêtes géographiques.

L’intégration avec Hibernate

Quand on se rapproche du monde Java et qu’on parle persistance, on arrive très vite à Hibernate. Ce framework a depuis longtemps pris en compte (au moins partiellement) les possibilités spatiales des bases de données avec extension spatiale par l’intermédiaire du projet ‘Hibernate Spatial’. Cette extension est même dorénavant intégrée à l’ORM depuis la version 5.

Les bases de données supportées sont plutôt nombreuses : Oracle 10g/11g, PostgreSQL/PostGIS, MySQL, Microsoft SQL Server et H2/GeoDB. Cependant, les possibilités offertes pour chacune d’entre elles ne sont pas équivalentes comme le montre le tableau :
http://www.hibernatespatial.org/documentation/03-dialects/01-overview/

Personnellement, je privilégie toujours PostgreSQL, qui est non seulement une excellente base de données mais également une véritable Rolls dès que des données spatiales doivent être manipulées. Cf. https://blog.ippon.fr/2017/01/03/rex-bdx-io-postgres-notonlysql/

Illustration avec JHipster

Objectif et étapes du projet

Rien de tel qu’un petit projet test pour illustrer l’utilisation possible des données spatiales. Pour cela, nous allons construire avec JHipster un web service permettant de récupérer la ville française la plus proche d’une coordonnée géographique donnée.

Le source complet de ce projet est disponible dans https://gitlab.ippon.fr/bpinel/geohipster

Pour arriver à ce résultat, nous allons passer par les étapes suivantes :

  • Mise en place d’un projet JHipster tournant sur une base PostgreSQL avec l’extension spatiale
  • Création d’un premier Web Service permettant l’import des villes françaises et de leur géolocalisation en utilisant le projet GeoNames.
  • Création, enfin, du Web Service d’interrogation de la base de données spatiale.

Génération d’un projet JHipster en spécifiant une base PostgreSQL.

Comme toujours avec JHipster, l’histoire commence avec la commande ‘jhipster’ (une fois JHipster installé bien évidemment !).

La capture d’écran suivante donne la liste des réponses à fournir au questionnaire :
Jhipster-Espace1-1

Avant d’aller plus loin, on doit également créer une base avec PostgreSQL avec l’extension spatiale activée, ainsi qu’un utilisateur pouvant l’exploiter.

On passera sur l’installation de la base et de son extension PostGIS (quasi immédiate sur Mac avec l’application postgresapp) pour passer directement à la création de la base nécessaire à notre application. Les personnes à l’aise avec Docker peuvent également utiliser un conteneur.

Pour cela, la commande ‘psql’ nous permet d’effectuer les commandes suivantes :

CREATE DATABASE geohipster;
CREATE USER geohipsteruser WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE geohipster TO geohipsteruser;
CREATE EXTENSION postgis;

Je n’entrerai pas plus dans les détails d’utilisation de PostgreSQL, sauf pour recommander l’utilisation de PgAdmin en version 4 qui facilite grandement les opérations spécifiques à PostgreSQL pour ceux qui ne maîtrisent pas parfaitement psql !

Ensuite, on va éditer les fichiers de configuration de l’application JHipster (dans resources/config, le fichier application-dev.yml en premier lieu) pour lui indiquer à la fois la base de données et le driver à utiliser (adapter les informations et notamment le port de la base de données selon votre installation) :

datasource:
   type: com.zaxxer.hikari.HikariDataSource
   url: jdbc:postgresql://localhost:5432/geohipster
   username: geohipsteruser
   password: password
jpa:
   database-platform: org.hibernate.spatial.dialect.postgis.PostgisDialect #io.github.jhipster.domain.util.FixedPostgreSQL82Dialect
   database: POSTGRESQL

Enfin, on va ajouter dans le build Gradle quelques dépendances nécessaires soit pour le binding JSON, soit pour le support des types géospatiaux :

compile "com.fasterxml.jackson.dataformat:jackson-dataformat-xml"
compile "com.google.code.gson:gson"
compile "org.hibernate:hibernate-spatial:${hibernate_version}"

(seule la dernière ligne concerne réellement la problématique spatiale, les deux premières étant liées au cas d’utilisation décrit dans l’exemple).

On peut alors lancer via ./gradlew l’application pour vérifier que les tables standards de JHipster sont bien créées (à l’aide d’un outil d’exploration de base ou de la commande psql) et que l’application se lance correctement.

Mais jusque-là, rien de franchement géolocalisé…

Import du référentiel des villes françaises

Nous allons utiliser les informations disponibles sur le site GeoNames et le lien Free Gazetter Data qui nous donne accès au fichier FR.zip, contenant les villes de France, leur coordonnées et diverses informations annexes.

Pour se faciliter la tâche, ce fichier est ajouté dans le répertoire ‘resources’ au sein d’un répertoire geonames, ce qui permettra de le charger depuis le classpath.

Une entité Geocity est également créée en parallèle sur JHipster en utilisant le générateur d’entité (avec les réponses données ci-dessous) :

> jhipster entity Geocity
- Do you want to use a Data Transfer Object (DTO)? No, use the entity directly
- Do you want to use separate service class for your business logic? Yes, generate a separate service interface and implementation
- Do you want pagination on your entity? Yes, with infinite scroll

Les différents attributs de l’entité seront créés ‘à la main’, car le générateur de Jhipster ne sait pas (encore !) gérer les types géographiques. Le code Java suivant présente la situation à laquelle on souhaite arriver :

package fr.ippon.geohipster.domain;

import com.google.gson.annotations.Expose;
import org.geolatte.geom.Point;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Objects;

/**
* A Geocity.
*/
@Entity
@Table(name = "geocity")
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class Geocity implements Serializable {

   private static final long serialVersionUID = 1L;

   @Id
   @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
   @SequenceGenerator(name = "sequenceGenerator")
   private Long id;

   @Column(name = "name")
   @Expose
   private String name;

   @Column(name = "asciiname")
   private String asciiname;
   @Column(name = "alternatenames")
   private String alternatenames;

   @Column(name = "location")
   private Point location;

   @Expose
   private double lat;
   @Expose
   private double lon;

   @Column(name = "featureclass")
   private String featureclass;

   @Column(name = "featuretype")
   private String featuretype;

   @Column(name = "countrycode")
   private String countrycode;

   @Column(name = "cc_2")
   private String cc2;

   @Column(name = "admin_1_code")
   private String admin1code;

   @Column(name = "admin_2_code")
   private String admin2code;

   @Column(name = "admin_3_code")
   private String admin3code;

   @Column(name = "admin_4_code")
   private String admin4code;

   @Column(name = "population")
   @Expose
   private Integer population;

   @Column(name = "elevation")
   @Expose
   private Integer elevation;

   @Column(name = "dem")
   private String dem;

  // Getters and Setters has been removed to shorten this piece of code

   @Override
   public boolean equals(Object o) {
       if (this == o) {
           return true;
       }
       if (o == null || getClass() != o.getClass()) {
           return false;
       }
       Geocity geocity = (Geocity) o;
       if (geocity.getId() == null || getId() == null) {
           return false;
       }
       return Objects.equals(getId(), geocity.getId());
   }

   @Override
   public int hashCode() {
       return Objects.hashCode(getId());
   }

   @Override
   public String toString() {
       return "Geocity{" +
           "id=" + getId() +
           ", name='" + getName() + "'" +
           ", asciiname='" + getAsciiname() + "'" +
           ", alternatenames='" + getAlternatenames() + "'" +
           ", location='(" + location.getPosition().getCoordinate(0)+ ", "+location.getPosition().getCoordinate(1)+")'" +
           ", featureclass='" + getFeatureclass() + "'" +
           ", featuretype='" + getFeaturetype() + "'" +
           ", countrycode='" + getCountrycode() + "'" +
           ", cc2='" + getCc2() + "'" +
           ", admin1code='" + getAdmin1code() + "'" +
           ", admin2code='" + getAdmin2code() + "'" +
           ", admin3code='" + getAdmin3code() + "'" +
           ", admin4code='" + getAdmin4code() + "'" +
           ", population='" + getPopulation() + "'" +
           ", elevation='" + getElevation() + "'" +
           ", dem='" + getDem() + "'" +
           "}";
   }
}

À noter, l’utilisation des annotations @Column pour le mapping des attributs et @Expose pour la mise à disposition de ces attributs dans le flux JSON.

Avant de redémarrer JHipster, il est nécessaire d’enrichir le schéma de la base de données et pour cela de modifier le fichier de modification Liquibase (20170914101249_added_entity_Geocity.xml) dans le répertoire resources/config/liquibase/changelog. Ce fichier doit reprendre les différents attributs ajoutés dans le bean Java :

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
   xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
   xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd
                       http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">

   <property name="now" value="now()" dbms="h2"/>

   <property name="now" value="current_timestamp" dbms="postgresql"/>

   <property name="floatType" value="float4" dbms="postgresql, h2"/>
   <property name="floatType" value="float" dbms="mysql, oracle, mssql"/>

   <!--
       Added the entity Geocity.
   -->
   <changeSet id="20170914101249-1" author="jhipster">
       <createTable tableName="geocity">
           <column name="id" type="bigint" autoIncrement="${autoIncrement}">
               <constraints primaryKey="true" nullable="false"/>
           </column>
           <column name="name" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="asciiname" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="alternatenames" type="varchar(1000)">
               <constraints nullable="true" />
           </column>

           <column name="location" type="geometry(Point, 4326)"/>
           <!-- We keep lat / lon columns for better visibility of data -->
           <column name="lat" type="float4"/>
           <column name="lon" type="float4"/>

           <column name="featureclass" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="featuretype" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="countrycode" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="cc_2" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="admin_1_code" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="admin_2_code" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="admin_3_code" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="admin_4_code" type="varchar(255)">
               <constraints nullable="true" />
           </column>
           <column name="population" type="integer">
               <constraints nullable="true" />
           </column>
           <column name="elevation" type="integer">
               <constraints nullable="true" />
           </column>
           <column name="dem" type="varchar(255)">
               <constraints nullable="true" />
           </column>
       </createTable>

   </changeSet>
</databaseChangeLog>

À noter, la colonne location et son type geometry(Point, 4326), le 4326 référençant le sphéroïde WGS84 (les adeptes de projections géographiques comprendront ! Les autres peuvent toujours aller faire un tour sur la page World Geodetic System).

On peut alors redémarrer JHipster et aller voir si la création de la table geocity s’est bien déroulée…

Au passage, on va créer un index sur la colonne location afin d’accélérer les requêtes. On passe à nouveau par la commande psql de PostgreSQL :

CREATE INDEX geocity_gix ON geocity USING GIST(location);

On va pouvoir maintenant ajouter un service Rest de chargement du fichier GeoNames des villes françaises en éditant la classe GeocityResource du package fr.ippon.geohipster.web.rest et en y ajoutant l’attribut et la méthode suivants :

private static final String FRGeonamesCityFile = "/geonames/cities_fr.csv";

@GetMapping("/loadGeoNamesCities")
public void loadGeoNamesCities(){
   log.info("Loading GeoName file "+FRGeonamesCityFile);
   InputStream stream = this.getClass().getResourceAsStream(FRGeonamesCityFile);

   try (BufferedReader br = new BufferedReader(new InputStreamReader(stream))) {

       String sCurrentLine;
       int nbCities = 0;
       while ((sCurrentLine = br.readLine()) != null) {
           String[] columns = sCurrentLine.split("\t");
           if (columns[7].startsWith("PPL")){
               Geocity geocity = new Geocity();
               geocity.setName(columns[1]);
               geocity.setAsciiname(columns[2]);
               geocity.setAlternatenames(columns[3]);

               Position g2d = new G2D(Double.valueOf(columns[5]), Double.valueOf(columns[4]));
               Point point = new Point(g2d, CrsRegistry.getCoordinateReferenceSystemForEPSG(4326, null));
               geocity.setLocation(point);
               // Also copy lat / lon to duplicate columns in the Database
               geocity.setLat(Double.valueOf(columns[4]));
               geocity.setLon(Double.valueOf(columns[5]));

               geocity.setFeatureclass(columns[6]);
               geocity.setFeaturetype(columns[7]);
               geocity.setCountrycode(columns[8]);
               geocity.setCc2(columns[9]);
               geocity.setAdmin1code(columns[10]);
               geocity.setAdmin2code(columns[11]);
               geocity.setAdmin3code(columns[12]);
               geocity.setAdmin4code(columns[13]);
               geocity.setPopulation(Integer.valueOf(columns[14]));
               if (columns[15].trim().length()==0)
                   geocity.setElevation(0);
               else
                   geocity.setElevation(Integer.valueOf(columns[15]));
               geocity.setDem(columns[16]);

               geocityService.save(geocity);
               nbCities++;
           }
       }
       log.info("Cities uploaded : "+nbCities);

   } catch (IOException e) {
       e.printStackTrace();
   }
}

(Un Web Service POST aurait surement été plus approprié puisque l’état interne du système est modifié).

En redémarrant l’application JHipster, puis en se logguant en administrateur, on peut accéder aux API Rest exposées :
Jhipster-Espace2

En dépliant les services offerts sur la ressource geocity, on dispose maintenant de la requête GET /api/loadGeoNamesCities qui va charger le fichier des villes dans la table (attention, à ne faire l’opération qu’une seule fois, la table n’étant pas vidée préalablement) :
Jhipster-Espace3

Ajout d’un service de recherche

Maintenant que notre base de données abrite un jeu de données géolocalisées, des services spécialisés vont pouvoir être créés comme par exemple la recherche des villes situées dans un cercle donné par les coordonnées géographiques de son centre et un rayon en kilomètres.

Pour cela, on va ajouter une nouvelle Query en s’appuyant sur les extensions spatiales disponibles (pas besoin de passer par des Query natives) :

Dans l’interface GeocityRepository :

@Query("SELECT c FROM Geocity c WHERE dwithin(c.location, :point, :dist)=true")
public List<Geocity> findCityWithinCircle(@Param("point")Geometry point, @Param("dist")float dist);

Dans l’interface de service GeocityService :

List<Geocity> findCitiesWithinCircle(float lat, float lon, int radius);

Et son implémentation GeocityServiceImpl :

@Override
public List<Geocity> findCitiesWithinCircle(float lat, float lon, int radius) {
   Position g2d = new G2D(lon, lat);
   Point point = new Point(g2d, CrsRegistry.getCoordinateReferenceSystemForEPSG(4326, null));
   float geoRadius = radius/111.0f; // 111 is the standard distance, in kilometers, of a degree
   List<Geocity> cities = geocityRepository.findCityWithinCircle(point, geoRadius);
   return cities;
}

Il ne reste plus qu’à ajouter le Web Service Rest de recherche des villes dans le cercle géographique en éditant une nouvelle fois GeocityResource :

@RequestMapping(value = "/geocities", method = GET, params = { "lat", "lon", "radius"})
@Timed
public String getGeocitiesWithinCircle(@Param("lat") float lat, @Param("lon")float lon, @Param("radius") int radius) {
   Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); // new Gson();
   List<Geocity> cities = geocityService.findCitiesWithinCircle(lat, lon, radius);

   return gson.toJson(cities);
}

On a alors un service Rest supplémentaire :
JHipster-Esapce-4

Reste à jouer avec cette interface (en fournissant cependant des coordonnées géographiques située sur la France).