Saturday, November 6, 2010

integrating elasticsearch and lift

I recently came across elasticsearch and was waiting for a good opportunity to try it out. The opportunity came in the shape a the new project I am working on which requires a lucene grade search engine and is in lift/sacala which lends itself well as I could use the java elasticsearch client api.

Elasticsearch is quite new, and I could not find real documentation on how to integrate it, however, going over the java source code and high level documentation on the web site, I was able to come up with the following simple integration:

First define a search engine abstraction:

object SearchEngine extends Logger
{
 private var _node:Node = null
 private var _client:Client = null
 
 def startup()
 {
  if (!enabled)
  {
   warn("search engine is disabled. if this is not intentional, please change the configuration file accordingly")
   return
  }
  
  _node = nodeBuilder().client(true).node
  if (null == _node) return
  _client = _node.client
 }
 
 def shutdown()
 {
  if (null != _node) _node.close();
  _client = null
 }
 
 def enabled:Boolean = "true" == (Props.get("search.enabled") openOr "false")
 
 def connected:Boolean = null != _client
 
 def index(indexName:String, typeName:String, identifier:String, json:JValue)
 {
  if (null == indexName) throw new Exception("invalid (null) index")
  if (null == typeName) throw new Exception("invalid (null) type")
  if (null == identifier) throw new Exception("invalid (null) identifier")
  if (null == json) throw new Exception("invalid (null) json")
  
  if (!enabled) return
  confirmConnection
  
  val request:IndexRequestBuilder = _client.prepareIndex(indexName, typeName, identifier)
  request.setSource(pretty(render(json)))
  val response:IndexResponse = request.execute().actionGet() 
 }
 
 def delete(indexName:String, typeName:String, identifier:String)
 {
  if (null == indexName) throw new Exception("invalid (null) index")
  if (null == typeName) throw new Exception("invalid (null) type")
  if (null == identifier) throw new Exception("invalid (null) identifier")
  
  if (!enabled) return
  confirmConnection
  
  val request:DeleteRequestBuilder = _client.prepareDelete(indexName, typeName, identifier)
  val response:DeleteResponse = request.execute().actionGet()
 }
 
 def search(indexName:String, query:XContentQueryBuilder, from:Integer, size:Integer, explain:Boolean=false):SearchHits =
 {
  if (null == indexName) throw new Exception("invalid (null) index")
  if (null == query) throw new Exception("invalid (null) query")
 
  if (!enabled) return null
  confirmConnection
 
  val request:SearchRequestBuilder = _client.prepareSearch(indexName)
  request.setSearchType(SearchType.QUERY_THEN_FETCH) 
  request.setQuery(query) 
  request.setFrom(from.intValue) 
  request.setSize(size.intValue) 
  request.setExplain(explain) 
 
  val response:SearchResponse = request.execute().actionGet()
  return response.hits
}
 
 private def confirmConnection
 {
  if (!connected) startup
  if (!connected) throw new Exception("cannot connect to search engine, perhaps it needs to be disabled?")
 }
 
}


Naturally, the next step is to add a call into "SearchEngine.startup" from your Boot.scala so the app connects to the search server on startup.

Next define a pair of model & companion traits to hide the integration details from the domain objects:

trait SearchableModelMeta[T <: SearchableModel[T]] extends BaseModelMeta[T]
{
 self: T  with SearchableModelMeta[T] with BaseModelMeta[T] =>
 
 private def searchIndexName:String = this.pluralXmlName
 
 private def searchTypeName:String = this.xmlName
 
 override def beforeSave = updateSearchIndexDate _ :: super.beforeSave

 override def afterSave = storeInSearchIndex _ :: super.afterSave
 
 override def afterDelete = deleteFromSearchIndex _ :: super.afterSave
 
 def reindexAll()
 {
  findAll.foreach((instance:T) => { storeInSearchIndex(instance) })
 }
 
 def search(query:XContentQueryBuilder, from:Integer=0, size:Integer=100):SearchHits =
 {
  SearchEngine.search(this.searchIndexName, query, from, size)
 }
 
 private def updateSearchIndexDate(instance:T)
 {
  instance.indexedAt(Helpers.now)
 }

 private def storeInSearchIndex(instance:T)
 {
  SearchEngine.index(this.searchIndexName, this.searchTypeName, instance.id.toString, instance.toJson("search"))
 }
 
 private def deleteFromSearchIndex(instance:T)
 {
  SearchEngine.delete(this.searchIndexName, this.searchTypeName, instance.id.toString)
 }
}

trait SearchableModel[T <: BaseModel[T]] extends BaseModel[T]
{
 self: T =>
 
 /*
 *** columns
 */
 
 object indexedAt extends MappedDateTime(this.asInstanceOf[T])
 {
  override def dbColumnName = "indexed_at"
 }
}


Note this example uses a custom base model class, which is not super important to what I am trying to show here (it facilitates dynamic json assembly and naming conventions, but those are pretty straight forward). however, it is important to note that that base model mixes in LongKeyedMapper and IdPk. headers for the class & companion below.

trait BaseModelMeta[T <: BaseModel[T]] extends LongKeyedMetaMapper[T]

trait BaseModel[T <: LongKeyedMapper[T]] extends LongKeyedMapper[T] with IdPK


Last mixin the SearchableMode traits with the domain model so you can do things like:

Employee.search(...)

This of course if a very basic example which uses a handful of the available features elasticsearch and lucene offer, however, this should give anyone trying to integrate elasticsearch with lift a good head start.

No comments:

Post a Comment