eduardofcbg / playframework2-elasticsearch

PlayFramework Java and Scala module for Elasticsearch

GitHub

This module integrates Elasticsearch into your PlayFramework app.

Versions

Module Playframework Elasticsearch Comments
0.11 2.5 2.3.2 Added scala support
0.1 2.4.3 2.0 Initial version

Install

Add the following resolver to your build.sbt

resolvers += "eduardofcbg" at "http://dl.bintray.com/eduardofcbg/maven"

And the following dependency declaration:

  "com.github.eduardofcbg" %% "playframework2-elasticsearch" % "0.11"

Your build.sbt should look something like this:

name := """myproject"""

version := "1.0-SNAPSHOT"

lazy val root = (project in file(".")).enablePlugins(PlayJava)

scalaVersion := "2.11.1"

libraryDependencies ++= Seq(
  cache,
  "com.github.eduardofcbg" %% "playframework2-elasticsearch" % "0.11"
)

resolvers += "eduardofcbg" at "http://dl.bintray.com/eduardofcbg/maven"

Configure the plugin

You should enable and configure the plugin in conf/application.conf

## ElasticSearch

## Enable the plugin
play.modules.enabled += "com.github.eduardofcbg.plugin.es.ESModule"

## Enable embedded database for developing
es.embed=true
## Where to save the embedded database (defaults to "db")
#es.local.path="db"

## For connecting to a remote database
## List of hosts
#es.hosts=["127.0.0.1:9300"]

## Name of the index (defaults to "play-es")
#es.index="play-es"

## Adicional options
#es.log=true
#es.sniff=true
#es.timeout=5
#es.ping=5
#es.cluster="mycluster"
#es.mappings="{ analysis: { analyzer: { my_analyzer: { type: \"custom\", tokenizer: \"standard\" } } } }"

Usage from Java

Make your models extend the com.github.eduardofcbg.plugin.es.Index class and annotate it with the name of the type that will be associated with the indexed objects of this class. On each model add a find helper. This is the object that you will use query your ES cluster.

package models;

@Type.Name("person")
@Type.ResultsPerPage(5)
public class Person extends Index {

	public String name;
	public int age;
	
	public static final Finder<Person> find = finder(Person.class);
	
	public Person(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	public Person() {}
	
	public CompletionStage<IndexResponse> index() {
		return find.index(this);
	}
	
	public static CompletionStage<Optional<Person>> get(String id) {
		return find.get(id);
	}
					
	public static CompletionStage<List<Person>> getAdults(int page) {
		return find.search(s -> s.setQuery(rangeQuery("age").from(18)), page);
	}
	
}

As you can see in the last method, you can easily construct any search query by passing a method that will change your SearchRequestBuilder object via side effects. This way you can use the ES java API directly in your models.

All the finder's methods are asynchronous. They return CompletionStage which can be easily turned into actual responses and even better, they can be turned into CompletionStage<Result>, being the Result the type returned by play's actions. This means you can easily construct asynchronous controllers like the one bellow.

public class PersonController extends Controller {

    @Inject
    public PersonController(ES es) {
        Person.registerAsType(es);
    }

    public CompletionStage<Result> getAdults(int page) {
        return Person.getAdults(page).thenApply(persons -> {
            ok(listOfPerson.render(persons));
        });
    }
    
    public CompletionStage<Result> getPerson(String id) {
        return Person.get(id).thenApply(p -> {
            if (p.isPresent()) ok(person.render(p.get()));
            else notFound("not found person with id " + id);
        });
    }

}

Additionally, every controller must register the type associated with it as seen above.

Of course you can also get the ES transport client by calling:

Finder.getClient();

You also can get the index name and the type any model, just by accessing it's Finder helper object.

Person.find.getType();
Person.find.getIndex();

There are also some methods that will help you parse a SearchResponse and a GetResponse:

Person person = find.parse(response);
List<Person> persons = find.parse(response);

##Dealing with concurrency problems

ElasticSearch uses the Optimistic concurrency control method. This plugin allows one to update a document without having to deal with the case when there are such problems.

public static CompletionStage<UpdateResponse> incrementAge(String id) {
        return find.update(id, original -> {
            original.age++;
            return original;
        });
}

An update can be done by specifying the document's Id and a Function that will be applied to the Object associated with it. This means that the Finder is aware of what transformation you are applying to the original object and can simply redo it if a problem related with concurrency occurs.

##Document relationships and mapping

You are able to set mappings in your index using the application.conf, but mappings that affect specific types should be specified in your actual models just by passing them when you create the Finder helper.

public static final Finder<Person> finder = finder(Person.class, m -> {
    try {
        m.startObject("_timestamp")
                .field("enabled", true)
                .field("path", "timestamp")
        .endObject();
    } catch (IOException e) { e.printStackTrace() }
});

Additionally, if you want to set a parent-child relationship or a nested type, it is as easy as setting an annotation:

package models;

@Type.Name("person")
@Type.ResultsPerPage(5)
public class Person extends Index {

	public String name;
	public int age;
	@Type.NestedField
	public List<Book> books;
	
	public static final Finder<Person> find = finder(Person.class);
	
	public Person(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	public Person() {}
	
	public static CompletionStage<List<Pet>> getPets(String personId, int page) {
	    return Pet.find.getAsChildrenOf(Person.class, personId, page);
	}
		
}
package models;

@Type.Name("pet")
@Type.Parent("person")
@Type.ResultsPerPage(5)
public class Pet extends Index {

	public String name;	
	
	public static final Finder<Pet> find = finder(Pet.class);
	
	public Pet(String name) {
		this.name = name;
	}
	
	public Pet() {}
	
	public CompletionStage<IndexResponse> index(String personId) {
		return find.indexChild(this, personId);
	}
		
}

A sample application based on this guide can be found here

Usage from Scala

Instead of the official Java API, elastic4s is exposed along with some methods for helping to build most of the common queries.

Conceptually the same as the Java API, the models simply look like this:

case class Person(
    name: String,
    age: Int,
    book: Book
) extends Index[Person]
object Person extends Index[Person] {
    implicit val format: Format[Person] = (
      (__ \ "name").format[String] and
      (__ \ "age").format[Int] and
      (__ \ "book").format[Book]
    )(Person.apply, unlift(Person.unapply))
    val finder = Finder()
}

case class Book(
    title: String,
    author: String
)
object Book {
    implicit val format: Format[Book] = (
      (__ \ "title").format[String] and
      (__ \ "author").format[String]
    )(Book.apply, unlift(Book.unapply))
}

Now instead of setting the mappings using annotations, we specify them when injecting the ES object into our models.

@Singleton
class Application @Inject() (es: ES, cache: CacheApi) {

    val client = RegisterClient(es, cache, mappings = 
        mapping("person") fields {
            field("books") typed NestedType
        }
    )
    
    def getPerson(personId: String) = Action.async {
        Person.finder(personId) map {
            case None => NotFound
            case Some(person) => Ok(Json.toJson(person))
        }
    }
    
    def getReading(bookTitle: String, page: Int) = Action.async {
        Person.finder.searchNestedTerm("book", "title", bookTitle)(page).map(persons => 
            Ok(Json.toJson(persons))
        )
    }
    
    def getPersonsByName(name: String, page: Int) = Action.async {
        Person.finder.searchWhere("name", name)(page).map(persons =>
            Ok(Json.toJson(persons))
        )
    }
            
}

Analogous to the Java API, the scala client is exposed through the finder instance as well:

val client = Person.finder.client