CLOVER🍀

That was when it all began.

Spring BootとHibernate Searchで遊ぶ

最近、Hibernate Searchの5.0.0.Finalがリリースされました。

Hibernate Search
http://hibernate.org/search/

Hibernate Search 5系から使用するLuceneが4.10系になったので、試してみようと思い、どうせならとSpring Bootを使って遊んでみました。

こんなものを使って遊んでます。

参考文献)
参考にしたのは、書籍「はじめてのSpring Boot」とCategolJ2 Backendですね。

では、作っていってみます。最初にpomを書いて、あとはデータアクセスからWeb側の層に向かって載せていきます(実際に作っていった順番は異なります)。

依存関係の定義

用意したpomは、こんな感じ。特徴的なところは、個別に書きます。
pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.littlewings</groupId>
  <artifactId>spring-boot-hibernate-search-integration</artifactId>
  <packaging>jar</packaging>
  <version>0.0.1-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.2.0.RELEASE</version>
  </parent>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>        
    </dependency>
	<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

    <!-- 個別に紹介 -->
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>

      <plugin>
        <groupId>net.alchim31.maven</groupId>
        <artifactId>scala-maven-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
          <execution>
            <goals>
              <goal>compile</goal>
              <goal>testCompile</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <scalaVersion>${scala.version}</scalaVersion>
          <args>
            <arg>-Xlint</arg>
            <arg>-unchecked</arg>
            <arg>-deprecation</arg>
            <arg>-feature</arg>
          </args>
          <recompileMode>incremental</recompileMode>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <java.version>1.8</java.version>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <scala.major.version>2.11</scala.major.version>
    <scala.version>${scala.major.version}.4</scala.version>
  </properties>
</project>

Spring Web、Spring Data JPAを加え、あとは以下のようなものを追加。

Scala

    <dependency>
      <groupId>org.scala-lang</groupId>
      <artifactId>scala-library</artifactId>
      <version>${scala.version}</version>
    </dependency>

MySQLJDBCドライバ。

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.34</version>
      <scope>runtime</scope>
    </dependency>

Hibernate SearchとKuromoji。形態素解析しますよ。

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-search-orm</artifactId>
      <version>5.0.0.Final</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-kuromoji</artifactId>
      <version>4.10.2</version>
    </dependency>

Jackson Module Scala。今回のpomで依存関係に引き込んでいるScalaとマイナーバージョンが合わないので、excludeしています。

    <dependency>
      <groupId>com.fasterxml.jackson.module</groupId>
      <artifactId>jackson-module-scala_${scala.major.version}</artifactId>
      <version>2.4.4</version>
      <exclusions>
        <exclusion>
          <groupId>org.scala-lang</groupId>
          <artifactId>scala-library</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

EntityとRepositoryの定義

Entityはひとつ用意。お題は書籍で。
src/main/scala/org/littlewings/springboot/hibernate/domain/Book.scala

package org.littlewings.springboot.hibernate.domain

import scala.beans.BeanProperty

import javax.persistence.{ Column, Entity, Id, Table }

import org.hibernate.search.annotations.{ Analyze, DocumentId, Field, Indexed }

@SerialVersionUID(1L)
@Entity
@Table(name = "book")
@Indexed
class Book extends Serializable {
  @Id
  @Field(analyze = Analyze.NO)
  @DocumentId(name = "id")
  @BeanProperty
  var isbn: String = _

  @Column
  @Field
  @BeanProperty
  var title: String = _

  @Column
  @Field(analyze = Analyze.NO)
  @BeanProperty
  var price: Int = _

  @Column
  @Field
  @BeanProperty
  var summary: String = _
}

普通のJPAのEntityに、Hibernate Searchのアノテーションを付けただけですね。Analyzerの設定は、application.ymlで行うことにしました。

Entityで、Documentのidになるフィールドをid以外の名前にすると、Hibernate Searchから怒られるんですね…。@DocumentIdアノテーションで指定してあげればよいみたいですが。

JPAリポジトリ
src/main/scala/org/littlewings/springboot/hibernate/repository/BookRepository.scala

package org.littlewings.springboot.hibernate.repository

import org.springframework.data.jpa.repository.{ JpaRepository, Query }

import org.littlewings.springboot.hibernate.domain.Book

trait BookRepository extends JpaRepository[Book, String] {
  @Query("SELECT b FROM Book b ORDER BY b.price ASC")
  def findOrderByPriceAsc: java.util.List[Book]
}

続いて、Hibernate Searchを使って全文検索するRepository。
src/main/scala/org/littlewings/springboot/hibernate/repository/BookSearchRepository.scala

package org.littlewings.springboot.hibernate.repository

import javax.persistence.{ EntityManager, PersistenceContext }

import org.apache.lucene.search.{ Query, Sort, SortField }
import org.hibernate.search.jpa.{ FullTextEntityManager, Search }
import org.hibernate.search.query.dsl.BooleanJunction
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Repository

import org.littlewings.springboot.hibernate.domain.Book

@Repository
class BookSearchRepository @Autowired() (@PersistenceContext private val entityManager: EntityManager) {
  def createFullTextEntityManager(entityManager: EntityManager) = Search.getFullTextEntityManager(entityManager)

  def searchTitleOrSummary(keyword: String): (Query, java.util.List[Book]) = {
    val fullTextEntityManager = createFullTextEntityManager(entityManager)

    val queryBuilder =
      fullTextEntityManager
        .getSearchFactory
        .buildQueryBuilder
        .forEntity(classOf[Book])
        .get

    // Splitした単語のAND
    val queries =
      keyword.split("\\s+").map { k =>
        queryBuilder
          .keyword
          .onFields("title", "summary")
          .matching(k)
          .createQuery
      }

    val luceneQuery =
      queries.foldLeft(queryBuilder.bool) { (acc, c) =>
        acc.must(c).asInstanceOf[BooleanJunction[BooleanJunction[_]]] }.createQuery

    /* そのままOR
    val luceneQuery =
      queryBuilder
        .keyword
        .onFields("title", "summary")
        .matching(keyword)
        .createQuery
     */

    (luceneQuery,
      fullTextEntityManager
        .createFullTextQuery(luceneQuery, classOf[Book])
        .setSort(new Sort(new SortField("price", SortField.Type.INT)))
        .getResultList
        .asInstanceOf[java.util.List[Book]])
  }
}

途中いろいろ遊んでいたので、ANDとORをそれぞれ試したりしていました。あと、クエリも見たかったので、Luceneのクエリを戻り値に含めています。

インデックスの初期構築は、今回は無視しました。

application.ymlは、JPAHibernate Search関係のことしか書いてないので、ここで載せます。
src/main/resources/application.yml

spring:
  datasource:
    driverClassName: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/practice?useUnicode=true&characterEncoding=utf-8&characterSetResults=utf-8&useServerPrepStmts=true&useLocalSessionState=true&elideSetAutoCommits=true&alwaysSendSetIsolation=false
    username: kazuhira
    password: password
  jpa:
    hibernate.ddl-auto: create-drop
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        search:
          default:
            directory_provider: ram
            # directory_provider: filesystem
            # indexBase: indexes
          analyzer: org.apache.lucene.analysis.ja.JapaneseAnalyzer
          lucene_version: LUCENE_4_10_2

Hibernate Searchで、Analyzerは

の3つの方法で設定ができます。

1.7. Analyzer
http://docs.jboss.org/hibernate/search/5.0/reference/en-US/html_single/#_analyzer

Service

Serviceクラス。ここでは、JPA全文検索リポジトリはまとめて扱ってしまいました。
src/main/scala/org/littlewings/springboot/hibernate/service/BookService.scala

package org.littlewings.springboot.hibernate.service

import scala.collection.JavaConverters._

import org.apache.lucene.search.Query
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

import org.littlewings.springboot.hibernate.domain.Book
import org.littlewings.springboot.hibernate.repository.{ BookRepository, BookSearchRepository }

@Service
@Transactional
class BookService @Autowired() (private val bookRepository: BookRepository,
                                             private val bookSearchRepository: BookSearchRepository) {
  def findAll: Seq[Book] =
    bookRepository.findOrderByPriceAsc.asScala.toVector

  def findOne(isbn: String): Book = bookRepository.findOne(isbn)

  def create(book: Book): Book = bookRepository.save(book)

  def update(book: Book): Book = bookRepository.save(book)

  def delete(isbn: String): Unit = bookRepository.delete(isbn)

  def deleteAll(): Unit = bookRepository.deleteAll()

  @Transactional(readOnly = true)
  def findTitleOrSummaryAsFullTextSearch(keyword: String): (Query, Seq[Book]) = {
    val result = bookSearchRepository.searchTitleOrSummary(keyword)
    (result._1, result._2.asScala.toVector)
  }
}

ScalaJavaのコレクションの変換は、Serviceクラスで行うようにしました。JPAjava.util.Listを返すので…。

RestController

RestContollerはこんな感じ。
src/main/scala/org/littlewings/springboot/hibernate/web/BookController.scala

package org.littlewings.springboot.hibernate.web

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.{ PathVariable, RequestBody, RequestMapping, RequestMethod, RestController }

import org.littlewings.springboot.hibernate.domain.Book
import org.littlewings.springboot.hibernate.service.BookService

@RestController
@RequestMapping(value = Array("book"))
class BookController @Autowired() (private val bookService: BookService) {
  @RequestMapping(value = Array("{isbn}"), method = Array(RequestMethod.GET))
  def find(@PathVariable isbn: String): Book = bookService.findOne(isbn)

  @RequestMapping(method = Array(RequestMethod.GET))
  def findAll: Seq[Book] = bookService.findAll

  @RequestMapping(method = Array(RequestMethod.POST))
  def createBooks(@RequestBody books: List[Book]): List[Book] =
    books.map(bookService.create)

  @RequestMapping(value = Array("{isbn}"), method = Array(RequestMethod.DELETE))
  def delete(@PathVariable isbn: String): Unit = bookService.delete(isbn)

  @RequestMapping(method = Array(RequestMethod.DELETE))
  def deleteAll(): Unit = bookService.deleteAll()

  @RequestMapping(value = Array("search"), method = Array(RequestMethod.GET))
  def search(@RequestBody request: SearchRequest): SearchResponse = {
    val result = bookService.findTitleOrSummaryAsFullTextSearch(request.query)
    SearchResponse(result._1.toString, result._2.size, result._2)
  }
}

最後のsearchメソッドが、全文検索用です。

他のメソッドはだいたい主キーかEntityを受け取りますが、全文検索のリクエスト/レスポンスは別の型を用意しました。
src/main/scala/org/littlewings/springboot/hibernate/web/SearchParams.scala

package org.littlewings.springboot.hibernate.web

import org.littlewings.springboot.hibernate.domain.Book

case class SearchRequest(query: String)

case class SearchResponse(fullTextQuery: String, hits: Int, books: Seq[Book])

Case Classですが、これを変換可能にするためにJackson Module Scalaを導入しています。

エントリポイント

最後、mainメソッドを持ったクラス。
src/main/scala/org/littlewings/springboot/hibernate/App.scala

package org.littlewings.springboot.hibernate

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import org.springframework.context.annotation.Bean
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication

object App {
  def main(args: Array[String]): Unit = SpringApplication.run(classOf[App], args: _*)
}

@SpringBootApplication
class App {
  @Bean
  def scalaObjectMapper: ObjectMapper = {
    val objectMapper = new ObjectMapper
    objectMapper.registerModule(DefaultScalaModule)
    objectMapper
  }
}

Jackson Scala Moduleの定義は、ここで行いました。

動作確認

それでは、動作確認(普通に作ったJPAの部分、ほとんど動かしてませんけど…)。

まず起動。

$ mvn spring-boot:run

登録対象としてデータをJSONで用意。
books.json

[
    {
        "isbn": "978-4777518654",
        "title": "はじめてのSpring Boot",
        "price": 2700,
        "summary": "「Spring Framework」で簡単Javaアプリ開発"
    },
    {
        "isbn": "978-4798124605",
        "title": "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
        "price": 4410,
        "summary": "エンタープライズJava入門書の決定版!Java EE 6は、大規模な情報システム構築に用いられるエンタープライズ環境向けのプログラミング言語です。"
    },
    {
        "isbn": "978-4774161631",
        "title": "[改訂新版] Apache Solr入門 オープンソース全文検索エンジン",
        "price": 3780,
        "summary": "最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。"
    },
    {
        "isbn": "978-4048662024",
        "title": "高速スケーラブル検索エンジン ElasticSearch Server",
        "price": 3024,
        "summary": "Apache Solrを超える全文検索エンジンとして注目を集めるElasticSearch Serverの日本初の解説書です。多くのサンプルを用いた実践的入門書になっています。"
    }
]

データ登録。

$ curl -X POST -H "Content-Type: application/json;" -d@books.json http://localhost:8080/book/

確認。

## 全件
$ curl http://localhost:8080/book
[{"isbn":"978-4777518654","title":"はじめてのSpring Boot","price":2700,"summary":"「Spring Framework」で簡単Javaアプリ開発"},{"isbn":"978-4048662024","title":"高速スケーラブル検索エンジン ElasticSearch Server","price":3024,"summary":"Apache Solrを超える全文検索エンジンとして注目を集めるElasticSearch Serverの日本初の解説書です。多くのサンプルを用いた実践的入門書になっています。"},{"isbn":"978-4774161631","title":"[改訂新版] Apache Solr入門 オープンソース全文検索エンジン","price":3780,"summary":"最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。"},{"isbn":"978-4798124605","title":"Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava","price":4410,"summary":"エンタープライズJava入門書の決定版!Java EE 6は、大規模な情報システム構築に用いられるエンタープライズ環境向けのプログラミング言語です。"}]

## 単一指定
$ curl http://localhost:8080/book/978-4777518654
{"isbn":"978-4777518654","title":"はじめてのSpring Boot","price":2700,"summary":"「Spring Framework」で簡単Javaアプリ開発"}

一応、なんか入ってます。

では、全文検索してみます。「全文検索 Solr」で検索。
*改行入れて整形してます

$ curl'Content-Type: application/json;' -d '{ "query": "全文検索 Solr"}' http://localhost:8080/book/search | perl -wp -e 's!{!\n{!g'
{"fullTextQuery":"+((title:全文 title:検索) (summary:全文 summary:検索)) +(title:solr summary:solr)","hits":2,"books":[
{"isbn":"978-4048662024","title":"高速スケーラブル検索エンジン ElasticSearch Server","price":3024,"summary":"Apache Solrを超える全文検索エンジンとして注目を集めるElasticSearch Serverの日本初の解説書です。多くのサンプルを用いた実践的入門書になっています。"},
{"isbn":"978-4774161631","title":"[改訂新版] Apache Solr入門 オープンソース全文検索エンジン","price":3780,"summary":"最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。"}]}

今回は、ホワイトスペースでsplitした単語のAND検索になっているので、クエリがこのような形になります。

+((title:全文 title:検索) (summary:全文 summary:検索)) +(title:solr summary:solr)

「Spring Java」で検索。

$ curl -X GET -H 'Content-Type: application/json;' -d '{ "query": "Spring Java"}' http://localhost:8080/book/search | perl -wp -e 's!{!\n{!g'
{"fullTextQuery":"+(title:spring summary:spring) +(title:java summary:java)","hits":1,"books":[
{"isbn":"978-4777518654","title":"はじめてのSpring Boot","price":2700,"summary":"「Spring Framework」で簡単Javaアプリ開発"}]}

単純にOR検索したい場合は、BookSearchRepositoryのコメントアウトしている箇所と現在のクエリを組み立てている部分を入れ替えるとよいです。それ以外にいろいろやりたい場合は、Hibernate SearchのQueryDSLのAPIを見ましょう。

なお、Kuromojiはデフォルトの状態なので、SEARCHモードになります。よって、「日本経済新聞」とクエリを投げると、このように形態素解析されます。

$ curl -X GET -H 'Content-Type: application/json;' -d '{ "query": "日本経済新聞"}' http://localhost:8080/book/search | perl -wp -e 's!{!\n{!g'
{"fullTextQuery":"(title:日本 title:日本経済新聞 title:経済 title:新聞) (summary:日本 summary:日本経済新聞 summary:経済 summary:新聞)","hits":1,"books":[
{"isbn":"978-4048662024","title":"高速スケーラブル検索エンジン ElasticSearch Server","price":3024,"summary":"Apache Solrを超える全文検索エンジンとして注目を集めるElasticSearch Serverの日本初の解説書です。多くのサンプルを用いた実践的入門書になっています。"}]}

あ、「日本」で引っかかった…。

形態素解析された結果は、この部分。

(title:日本 title:日本経済新聞 title:経済 title:新聞) (summary:日本 summary:日本経済新聞 summary:経済 summary:新聞)

こんな感じに展開されるのが、KuromojiのSEARCHモードです。

とりあえず、やりたい最低限はできました!

それにしても、起動が重くてうちのPCだとちょっとつらくなってきました…。