最近、Hibernate Searchの5.0.0.Finalがリリースされました。
Hibernate Search
http://hibernate.org/search/
Hibernate Search 5系から使用するLuceneが4.10系になったので、試してみようと思い、どうせならとSpring Bootを使って遊んでみました。
こんなものを使って遊んでます。
- Spring Boot
- Spring MVC
- Spring Data JPA
- Hibernate Search
- Kuromoji(Lucene版)
- Scala
- Jackson Module Scala
- MySQL
参考文献)
参考にしたのは、書籍「はじめての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を加え、あとは以下のようなものを追加。
<dependency> <groupId>org.scala-lang</groupId> <artifactId>scala-library</artifactId> <version>${scala.version}</version> </dependency>
<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は、JPAとHibernate 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) } }
ScalaとJavaのコレクションの変換は、Serviceクラスで行うようにしました。JPAがjava.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だとちょっとつらくなってきました…。