Accueil > Veille > Notre blog de veille > Module Elasticsearch pour Play Framework 2

Module Elasticsearch pour Play Framework 2

Nicolas Boire Matthieu Guillermin 22 avril 2013
0 commentaire

Play Framework et Elasticsearch sont deux outils récents très populaires. Afin d’intégrer facilement ces deux outils, nous utilisons dans nos projets un module pour Play Framework 2 que nous avons développé. Voyons ensemble comment fonctionne ce module.

Historique

Lors de différents projets développés sous Play Framework 2, nous avons eu besoin d’intégrer Elasticsearch. Aucun module n’existait permettant de rendre cette intégration simple, nous avons donc décidé de bâtir le notre.

Tout d’abord développé dans un dépôt privé, après quelques itérations nous avons obtenu un module suffisamment stable pour être utilisé par d’autres personnes et nous l’avons donc mis à disposition sous licence MIT sur le compte Github de Clever Age.

Dernièrement, une API dédiée à Scala a été mise en place autour de l’API initiale du module. Nous allons présenter dans cet article les grands principes de fonctionnement de ce module.

Playframework

Que fait le module ?

Ce module permet d’utiliser Elasticsearch soit :

  • en mode local : pour embarquer un serveur Elasticsearch ( très utile pour les tests )
  • en mode client : pour se connecter à un ( ou plusieurs ) noeuds Elasticsearch

Lors du démarrage de l’application Play, le module va instancier un “Client” qui permettra ensuite d’intérargir avec Elasticsearch.

On peut ensuite créer un (ou plusieurs) Index(s), Type(s) dont on peut initialiser les “settings” et les “mappings” si besoin.

Le module permet d’effectuer des opérations d’indexation, de lecture et de suppression sur un “Index”/“Type” donné.

Il permet principalement d’effectuer des recherches avec la possibilité de lui demander des facettes. Nous avons également mis en place des méthodes pour faciliter l’utilisation des “Percolator”.

Installation

Comme tout module Play, il suffit de déclarer la dépendance dans le fichier Build.scala pour pouvoir récupérer et utiliser le module. Il est actuellement publié sur le dépôt communautaire de SBT, il faut donc également déclarer ce dépôt. Voici donc les dépendances à déclarer :

  1. import sbt._
  2. import Keys._
  3. import play.Project._
  4. object ApplicationBuild extends Build {
  5.     val appName         = "elasticsearch-sample"
  6.     val appVersion      = "1.0-SNAPSHOT"
  7.     val appDependencies = Seq(
  8.         // Add your project dependencies here,
  9.         "com.clever-age" % "play2-elasticsearch" % "0.5.3"
  10.     )
  11.     val main = play.Project(appName, appVersion, appDependencies).settings(
  12.         // Add your own project settings here      
  13.         resolvers += Resolver.url("play-plugin-releases", new URL("http://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/"))(Resolver.ivyStylePatterns),
  14.         resolvers += Resolver.url("play-plugin-snapshots", new URL("http://repo.scala-sbt.org/scalasbt/sbt-plugin-snapshots/"))(Resolver.ivyStylePatterns)
  15.     )
  16. }

Activation

Il faut ensuite activer le module en le définissant dans le fichier “conf/play.plugins”

11000:com.github.cleverage.elasticsearch.plugin.IndexPlugin

Configuration

Une fois la dépendance récupérée, différentes options peuvent être ajoutées dans le fichier application.conf ou dans un fichier de configuration dédié inclus dans ce dernier.

  1. elasticsearch {
  2.   # permet d'utiliser elasticsearch de manière "embarquée" à l'application
  3.   local: true
  4.   # liste des Node elasticsearch auxquels se connecter
  5.   client: "127.0.0.1:9300,127.0.0.1:9301"
  6.   # cluster.name elasticsearch auquel se connecter
  7.   cluster.name: "elasticsearch"
  8.   index {
  9.     # le(s) nom(s) d'index qui sera(ont) utilisé(s) dans l'application
  10.     name: play2-elasticsearch,log
  11.     # pattern définissant les classes "indexables"
  12.     clazzs: "indexing.*"
  13.     # mapping elasticsearch qui seront appliqué au démarrage de l'application
  14.    mappings: {
  15.      # la clé est le "type" elasticsearch et la valeur est le mapping
  16.      "indexTest": "{\"indexTest\":{\"properties\":{\"category\":{\"type\":\"string\",\"analyzer\":\"keyword\"}}}}"
  17.    }
  18.    # activation du log des requêtes (logs effectués au niveau "DEBUG")
  19.    show_request: true,
  20.  }
  21.  # paramètres additionnels qui seront appliqués sur l'index au démarrage
  22.   play2-elasticsearch.settings: "{ analysis: { analyzer: { team_name_analyzer: { type: \"custom\", tokenizer: \"standard\" } } } }"
  23. }  

Java

En Java, pour indexer et requêter Elasticsearch, il faut définir une classe qui correspond aux données que vous souhaitez indexer. Cette classe doit hériter de la classe "Index" définie dans le module et définir les méthodes toIndex() et fromIndex().

On définit le "type" Elasticsearch à l’aide de l’annotation @IndexType(). Optionnellement, on peut spécifier l’index utilisé à l’aide de l’annotation @IndexName (par défaut, le premier défini dans la configuration sera utilisé).

Ex :

  1. @IndexType(name = "indexTest")
  2. public class IndexTest extends Index {
  3.     public String name;
  4.     // Finder used to request ElasticSearch
  5.     public static Finder<IndexTest> find = new Finder<IndexTest>(IndexTest.class);
  6.     @Override
  7.     public Map toIndex() {
  8.         Map<String, Object> map = new HashMap<String, Object>();
  9.         map.put("name", name);
  10.         return map;
  11.     }
  12.     @Override
  13.     public Indexable fromIndex(Map map) {
  14.         this.name = (String) map.get("name");
  15.         return this;
  16.     }
  17. }

Lors de la création du Finder, il est possible également de cibler un index spécifique. Ex : new Finder<IndexTest>(IndexTest.class, "log");

Il est alors possible d’indexer et de requêter elasticsearch comme ceci :

  1. IndexTest indexTest = new IndexTest();
  2. indexTest.name = "hello World";
  3. indexTest.index();
  4. IndexTest byId = IndexTest.find.byId("1");
  5. IndexResults<IndexTest> all = IndexTest.find.all();
  6. IndexQuery<IndexTest> indexQuery = IndexTest.find.query();
  7. indexQuery.setBuilder(QueryBuilders.queryString("hello"));
  8. IndexResults<IndexTest> results = IndexTest.find.search(indexQuery);

Toutes les possibilités offertes par l’API Elasticsearch peuvent être passés à l’object IndexQuery, ainsi que les FacetBuilder et SortBuilder.

Si vous souhaitez appliquer un mapping particulier sur un type donnée, il est possible de le faire à l’aide de l’annotation @IndexMapping. Ex :

  1. @IndexType(name = "team")
  2. @IndexMapping(value = "{ players : { properties : { players : { type : \"nested\" }, name : { type : \"string\", analyzer : \"team_name_analyzer\" } } } }")
  3. public class Team extends Index {
  4.     public String name;
  5.     public Date dateCreate;
  6.     public String level;
  7.     public Country country;
  8.     public List<Player> players = new ArrayList<Player>();
  9.     // Finder used to request ElasticSearch
  10.     public static Finder<Team> find = new Finder<Team>(Team.class);

Scala

Pour utiliser le module en profitant pleinement de Scala, il est possible d’utiliser l’API définie dans le module ScalaHelpers. Les principes sont à peu près les mêmes, mais il est possible d’utiliser des case class pour définir les objets à indexer et les résultats de recherche seront accessible sous forme de collections Scala permettant d’utiliser toutes les méthodes associées (map(), filter(), …).

Il vous faut donc tout d’abord créer une case class qui devra étendre le trait ScalaHelpers.Indexable :

  1. case class IndexableTest(id: String, name: String, category: String) extends Indexable

Associé à cette classe, un object qui étend le trait ScalaHelpers.IndexableManager[T] permettra d’intéragir avec Elasticsearch. :

  1. object IndexableTestManager extends IndexableManager[IndexableTest] {
  2.     import play.api.libs.json._
  3.     // Obligatoire : le type Elasticsearch à utiliser
  4.     val indexType = "indexableTest"
  5.     // Optionnel : le nom de l'index si on ne veut pas utiliser le premier déclarer dans la conf
  6.     // val index = "log"
  7.     val reads: Reads[IndexableTest] = Json.reads[IndexableTest]
  8.     val writes: Writes[IndexableTest] = Json.writes[IndexableTest]
  9. }

Ce singleton doit définir des parsers JSON (Reads et Writes). Dans le cas classique, l’utilisation des macros Json.reads[T] & Json.writes[T] feront tout ce qu’il faut. Si un formattage JSON particulier est nécessaire, il faudra les définir manuellement.

Une fois ces éléments définis, il est possible d’intéragir avec l’index de cette manière :

  1. IndexableTestManager.index(IndexableTest("1", "first name", "cateogory A"))
  2. assert(IndexableTestManager.get("1") == IndexableTest("1", "first name", "cateogory A")
  3. val indexQuery = IndexQuery[IndexableTest]().withBuilder(QueryBuilders.matchQuery("name", "first"))
  4.   //.withSize(...)
  5.   //.withFrom(...)
  6.   //.addFacet(...)
  7.   //.addSort(...)
  8. val queryResults: IndexResults[IndexableTest] = IndexableTestManager.search(indexQuery)
  9. println(queryResults.results.map(_.name).mkString(",")) val count = queryResults.totalCount
  10. IndexableTestManager.delete("1")

Asynchrone

Toutes les actions (indexation, recherche,...) peuvent être éxécutées de manière non-bloquante. Les différentes API retournent alors des "Futures". Il est alors possible de les utiliser dans des actions de controlleurs non-bloquantes. C’est le cas pour l’API Java et l’API Scala, il suffit d’utiliser les méthodes suffixés par "Async".

Ex en Scala :

  1. object Application extends Controller {
  2.   def async = Action {
  3.     IndexTestManager.index(IndexTest("1", "Here is the first name", "First category"))
  4.     IndexTestManager.index(IndexTest("2", "Then comes the second name", "First category"))
  5.     IndexTestManager.index(IndexTest("3", "Here is the third name", "Second category"))
  6.     IndexTestManager.index(IndexTest("4", "Finnaly is the fourth name", "Second category"))
  7.     IndexTestManager.refresh()
  8.     val indexQuery = IndexTestManager.query
  9.       .withBuilder(QueryBuilders.matchQuery("name", "Here"))
  10.     val indexQuery2 = IndexTestManager.query
  11.       .withBuilder(QueryBuilders.matchQuery("name", "third"))
  12.     // Combining futures
  13.     val l: Future[(IndexResults[IndexTest], IndexResults[IndexTest])] = for {
  14.       result1 <- IndexTestManager.searchAsync(indexQuery)
  15.       result2 <- IndexTestManager.searchAsync(indexQuery2)
  16.     } yield (result1, result2)
  17.     Async {
  18.       l.map { case (r1, r2) =>
  19.         Ok(r1.totalCount + " - " + r2.totalCount)
  20.       }
  21.     }
  22.   }
  23. }

Conclusion

Si vous avez besoin d’intégrer facilement Elasticsearch dans votre projet Playframework, n’hésitez pas à tester ce module. Vous le trouverez sur notre dépôt Github - Les tests sont éxécutés automatiquement à l’aide de Travis-CI.

Les futures versions devraient proposer les améliorations suivantes :

  • Gestion des documents Parent/Child de manière simple
  • Développement d’une application d’exemple permettant d’illustrer les différentes fonctionnalités
  • Amélioration de la documentation

Toutes les remontées et les Pull Requests sont les bienvenues !

Développement Web Java, Architecture Logicielle, Clever Age, Elasticsearch, Play

Par Nicolas Boire, Matthieu Guillermin