前に1度挫折したネタを、別のアプローチでやってみようということで。
以前、Spring BootとHibernate Search、そしてInfinispanを使って、こんなエントリを書きました。
Spring Boot×Hibernate Searchで、インデックスを複数Nodeで共有する
http://d.hatena.ne.jp/Kazuhira/20141223/1419347497
この方法では、Hibernate SearchのLuceneのインデックスの保存先をInfinispanとして、その設定方法はクラスパス上の設定ファイルとしました。ただ、これだとInfinispanのインスタンスがHibernate Searchに握られてしまうので、他の用途に使えません。
で、JNDIリソースとして定義しようとしたのがこちら。
組み込みTomcat(on Spring Boot)でJNDIリソースを扱う
http://d.hatena.ne.jp/Kazuhira/20141227/1419709045
でまあ、失敗したわけですよ。組み込みTomcatにJNDIリソースとしてねじ込んでも、アプリケーションの起動時にHibernate Searchから見えなくて。
その後、もうちょっとHibernate Searchの実装を見ていると、意外とできるんじゃないか?という気がしまして、再度チャレンジということでやってみました。
今回は、Hibernate Searchで定義したInfinispanのCacheManagerを、SpringのCache機能でも共有するようにしてみます。
なお、このエントリを書くにあたっての参考情報は、こちらです。
SpringのCache Abstractionについて
https://blog.ik.am/#/entries/339
Cache Abstraction
http://docs.spring.io/spring/docs/current/spring-framework-reference/html/cache.html
Using Infinispan as a Spring Cache provider
http://infinispan.org/docs/7.2.x/user_guide/user_guide.html#_using_infinispan_as_a_spring_cache_provider
準備
用意したpomはこちら。Spring BootでSpring MVCと、Spring Data JPAを使います。
pom.xml
<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/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.littlewings</groupId> <artifactId>hibernate-search-infinispan-cache</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.2.3.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> </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> </properties> </project>
個々の依存関係は、別に説明します。
データベースはMySQLとするので、JDBCドライバを追加。
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.35</version> <scope>runtime</scope> </dependency>
Hibernate Searchの利用とインデックスの保存先はInfinispanということで、これらの依存関係を追加。Lucene Kuromojiは蛇足な気がします…。
<dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-search-orm</artifactId> <exclusions> <exclusion> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> </exclusion> </exclusions> <version>5.2.0.Final</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-search-infinispan</artifactId> <version>5.2.0.Final</version> </dependency> <dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-analyzers-kuromoji</artifactId> <version>4.10.4</version> </dependency>
Hibernate Searchが見ているHibernate ORM/EntityManagerが微妙にSpring Boot JPAより新しいらしく、そのままだと動かなかったのでHibernate SearchからHibernateを除外しました。
SpringのCacheとしてInfinispanを使うために、Spring 4用のモジュールを追加。
<dependency> <groupId>org.infinispan</groupId> <artifactId>infinispan-spring4</artifactId> <version>7.2.0.Final</version> </dependency>
最後に、主題と関係ありませんが、Lombok。
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.4</version> <scope>provided</scope> </dependency>
依存関係の定義は、こんな感じです。
テーブル定義
JPAで使うテーブルの定義は、こんな感じ。
CREATE TABLE article( id INT AUTO_INCREMENT, contents VARCHAR(255), PRIMARY KEY(id) );
主キー以外は、1カラムだけ。とにかく、簡単にいきます。
設定ファイル
Spring Bootで使う設定ファイルは、このように定義しました。
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: none properties: hibernate: show_sql: true format_sql: true search: default: directory_provider: infinispan infinispan.configuration_resourcename: infinispan.xml analyzer: org.apache.lucene.analysis.ja.JapaneseAnalyzer lucene_version: LUCENE_4_10_4
Infinispanの設定ファイルは、「infinispan.xml」として定義。
で、Infinispanの設定ファイルは、このように用意。
src/main/resources/infinispan.xml
<?xml version="1.0" encoding="UTF-8"?> <infinispan xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:infinispan:config:7.2 http://www.infinispan.org/schemas/infinispan-config-7.2.xsd" xmlns="urn:infinispan:config:7.2"> <jgroups> <stack-file name="udp" path="jgroups.xml"/> </jgroups> <cache-container name="indexingCacheManager" shutdown-hook="DONT_REGISTER"> <transport cluster="cluster" stack="udp"/> <distributed-cache name="springCache"> <expiration lifespan="10000" max-idle="-1"/> </distributed-cache> <distributed-cache name="LuceneIndexesData" mode="SYNC" remote-timeout="25000"> <transaction mode="NONE"/> <state-transfer enabled="true" timeout="480000" await-initial-transfer="true"/> <indexing index="NONE"/> <locking striping="false" acquire-timeout="10000" concurrency-level="500" write-skew="false"/> <eviction max-entries="-1" strategy="NONE"/> <expiration max-idle="-1"/> </distributed-cache> <replicated-cache name="LuceneIndexesMetadata" mode="SYNC" remote-timeout="25000"> <transaction mode="NONE"/> <state-transfer enabled="true" timeout="480000" await-initial-transfer="true"/> <indexing index="NONE"/> <locking striping="false" acquire-timeout="10000" concurrency-level="500" write-skew="false"/> <eviction max-entries="-1" strategy="NONE"/> <expiration max-idle="-1"/> </replicated-cache> <replicated-cache name="LuceneIndexesLocking" mode="SYNC" remote-timeout="25000"> <transaction mode="NONE"/> <state-transfer enabled="true" timeout="480000" await-initial-transfer="true"/> <indexing index="NONE"/> <locking striping="false" acquire-timeout="10000" concurrency-level="500" write-skew="false"/> <eviction max-entries="-1" strategy="NONE"/> <expiration max-idle="-1"/> </replicated-cache> </cache-container> </infinispan>
「springCache」というのは、SpringのCache機能で使うために定義したものです。
<distributed-cache name="springCache"> <expiration lifespan="10000" max-idle="-1"/> </distributed-cache>
10秒間のTTLを設定しています。
その他のCacheは、すべてインデックスの保存等のLucene関係で使われます。
クラスタ構成ができるように定義しているので、JGroupsの設定も入れておきます。
src/main/resources/jgroups.xml
<?xml version="1.0" encoding="UTF-8"?> <config xmlns="urn:org:jgroups" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:org:jgroups http://www.jgroups.org/schema/JGroups-3.6.xsd"> <UDP mcast_addr="${jgroups.udp.mcast_addr:228.6.7.8}" mcast_port="${jgroups.udp.mcast_port:46655}" ucast_recv_buf_size="150k" ucast_send_buf_size="130k" mcast_recv_buf_size="150k" mcast_send_buf_size="130k" ip_ttl="${jgroups.ip_ttl:2}" thread_naming_pattern="pl" enable_diagnostics="false" thread_pool.min_threads="${jgroups.thread_pool.min_threads:2}" thread_pool.max_threads="${jgroups.thread_pool.max_threads:30}" thread_pool.keep_alive_time="60000" thread_pool.queue_enabled="false" internal_thread_pool.min_threads="${jgroups.internal_thread_pool.min_threads:5}" internal_thread_pool.max_threads="${jgroups.internal_thread_pool.max_threads:20}" internal_thread_pool.keep_alive_time="60000" internal_thread_pool.queue_enabled="true" internal_thread_pool.queue_max_size="500" oob_thread_pool.min_threads="${jgroups.oob_thread_pool.min_threads:20}" oob_thread_pool.max_threads="${jgroups.oob_thread_pool.max_threads:200}" oob_thread_pool.keep_alive_time="60000" oob_thread_pool.queue_enabled="false" /> <PING/> <MERGE3 min_interval="10000" max_interval="30000" /> <FD_SOCK/> <FD_ALL timeout="60000" interval="15000" timeout_check_interval="5000" /> <VERIFY_SUSPECT timeout="5000" /> <pbcast.NAKACK2 xmit_interval="1000" xmit_table_num_rows="50" xmit_table_msgs_per_row="1024" xmit_table_max_compaction_time="30000" max_msg_batch_size="100" resend_last_seqno="true" /> <UNICAST3 xmit_interval="500" xmit_table_num_rows="50" xmit_table_msgs_per_row="1024" xmit_table_max_compaction_time="30000" max_msg_batch_size="100" conn_expiry_timeout="0" /> <pbcast.STABLE stability_delay="500" desired_avg_gossip="5000" max_bytes="1M" /> <pbcast.GMS print_local_addr="true" join_timeout="15000" /> <!-- <tom.TOA/> --> <!-- the TOA is only needed for total order transactions--> <UFC max_credits="2m" min_threshold="0.40" /> <MFC max_credits="2m" min_threshold="0.40" /> <FRAG2/> </config>
設定ファイル関係は、ここまで。
Spring Data JPAとHibernate Searchを使ったサンプル
では、アプリケーションを書いていきます。
まずは、Spring Data JPAとHibernate Searchを使ってサンプルの部分。
JPAのEntityは、このように定義。
src/main/java/org/littlewings/spring/domain/Article.java
package org.littlewings.spring.domain; import java.io.Serializable; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.search.annotations.Field; import org.hibernate.search.annotations.Indexed; @Data @NoArgsConstructor @Entity @Table(name = "article") @Indexed public class Article implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @Column @Field private String contents; public Article(String contents) { this.contents = contents; } }
Lombokを使って、短く。
RestController。Controllerと言いながらEntityManagerなどをゴリゴリと使いますが、Repositoryとか書いていくとちょっと収まらなくなるので…。
src/main/java/org/littlewings/spring/web/SearchController.java
package org.littlewings.spring.web; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.PostConstruct; import javax.persistence.EntityManager; import org.apache.lucene.search.Sort; import org.apache.lucene.search.SortField; import org.hibernate.search.jpa.FullTextEntityManager; import org.hibernate.search.jpa.Search; import org.hibernate.search.query.dsl.BooleanJunction; import org.hibernate.search.query.dsl.QueryBuilder; import org.littlewings.spring.domain.Article; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("search") @Transactional public class SearchController { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private EntityManager entityManager; @RequestMapping(value = "create", method = RequestMethod.PUT) public Article create(@RequestParam String contents) { Article article = new Article(contents); entityManager.persist(article); return article; } @RequestMapping(value = "get/{id}", method = RequestMethod.GET) public Article get(@PathVariable Long id) { return entityManager.find(Article.class, id); } @RequestMapping(value = "search", method = RequestMethod.GET) @SuppressWarnings("unchecked") public List<Article> search(@RequestParam String keyword) { FullTextEntityManager fullTextEntityManager = getFullTextEntityManager(); QueryBuilder queryBuilder = fullTextEntityManager .getSearchFactory() .buildQueryBuilder() .forEntity(Article.class) .get(); List<org.apache.lucene.search.Query> queries = Stream .of(keyword.split("\\s+")) .map(k -> queryBuilder.keyword().onField("contents").matching(k).createQuery()) .collect(Collectors.toList()); BooleanJunction<BooleanJunction> booleanJunction = queryBuilder.bool(); queries.stream().forEach(q -> booleanJunction.must(q)); org.apache.lucene.search.Query luceneQuery = booleanJunction.createQuery(); logger.info("Lucene Query => {}", luceneQuery); return (List<Article>) fullTextEntityManager .createFullTextQuery(luceneQuery, Article.class) .setSort(new Sort(new SortField("id", SortField.Type.LONG))) .getResultList(); } private FullTextEntityManager getFullTextEntityManager() { return Search.getFullTextEntityManager(entityManager); } /* @PostConstruct public void reindexing() throws InterruptedException { logger.info("indexing..."); getFullTextEntityManager().createIndexer().startAndWait(); logger.info("done."); } */ }
最後のこの部分は、単一Nodeであればアプリケーション起動時に再インデキシングしてくれるのですが、クラスタ構成にするとちょっとうまくいかなくなるので、コメントアウトしています。
@PostConstruct public void reindexing() throws InterruptedException { logger.info("indexing..."); getFullTextEntityManager().createIndexer().startAndWait(); logger.info("done."); }
SpringのCache機能を使う部分
続いては、SpringのCache機能を使ったクラスを書きます。
SpringのCacheのアノテーションを使って、効果を確認するためのServiceクラス。
src/main/java/org/littlewings/spring/service/CalcService.java
package org.littlewings.spring.service; import java.util.concurrent.TimeUnit; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @Service @CacheConfig(cacheNames = "springCache") public class CalcService { @Cacheable public int add(int a, int b) { try { TimeUnit.SECONDS.sleep(3L); } catch (InterruptedException e) { } return a + b; } }
そして、Cacheのアノテーションを使ったクラスを使う、RestController。
src/main/java/org/littlewings/spring/web/CacheController.java
package org.littlewings.spring.web; import org.littlewings.spring.service.CalcService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("cache") public class CacheController { @Autowired private CalcService calcService; @RequestMapping(value = "add", method = RequestMethod.GET) public Integer add(@RequestParam int a, @RequestParam int b) { return calcService.add(a, b); } }
ここまでは、割と単純です。
JavaConfig
で、今回苦労したのがJavaConfigです。最終的に、こんな定義になりました。
src/main/java/org/littlewings/spring/config/Config.java
package org.littlewings.spring.config; import javax.persistence.EntityManager; import org.hibernate.search.FullTextSession; import org.hibernate.search.infinispan.spi.CacheManagerService; import org.hibernate.search.jpa.FullTextEntityManager; import org.hibernate.search.jpa.Search; import org.hibernate.search.spi.SearchIntegrator; import org.infinispan.manager.EmbeddedCacheManager; import org.infinispan.spring.provider.SpringEmbeddedCacheManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; @Configuration @EnableCaching public class Config { @Autowired private EntityManager entityManager; @Autowired private PlatformTransactionManager transactionManager; @Bean public EmbeddedCacheManager embeddedCacheManager() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); return transactionTemplate.execute(status -> { status.setRollbackOnly(); FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager); FullTextSession session = fullTextEntityManager.unwrap(FullTextSession.class); SearchIntegrator integrator = session.getSearchFactory().unwrap(SearchIntegrator.class); CacheManagerService cacheManagerService = integrator.getServiceManager().requestService(CacheManagerService.class); return cacheManagerService.getEmbeddedCacheManager(); }); } @Bean public CacheManager cacheManager() { return new SpringEmbeddedCacheManager(embeddedCacheManager()); } }
Hibernate Searchが管理しているInfinispanのCacheManagerを引っこ抜くには、こんなコードを書けばいいのかなーと実装を見ていて思いまして。
@Bean public EmbeddedCacheManager embeddedCacheManager() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); return transactionTemplate.execute(status -> { status.setRollbackOnly(); FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager); FullTextSession session = fullTextEntityManager.unwrap(FullTextSession.class); SearchIntegrator integrator = session.getSearchFactory().unwrap(SearchIntegrator.class); CacheManagerService cacheManagerService = integrator.getServiceManager().requestService(CacheManagerService.class); return cacheManagerService.getEmbeddedCacheManager(); }); }
ただ、ロールバックするように設定しているとはいえ、トランザクションを使っているところが微妙ですね…。
最初、こんな感じで書いていたら
@Bean public EmbeddedCacheManager embeddedCacheManager() { FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager); FullTextSession session = fullTextEntityManager.unwrap(FullTextSession.class); SearchIntegrator integrator = session.getSearchFactory().unwrap(SearchIntegrator.class); CacheManagerService cacheManagerService = integrator.getServiceManager().requestService(CacheManagerService.class); return cacheManagerService.getEmbeddedCacheManager(); }
トランザクションがないよと怒られまして。
Caused by: java.lang.IllegalStateException: No transactional EntityManager available at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:267)
EntityManagerからトランザクションを開始するのもNGらしく、SpringかJTAを使えという感じだったので、今回はTransactionTemplateを使って実装しました。
Transaction Management
http://docs.spring.io/spring/docs/current/spring-framework-reference/html/transaction.html
エントリポイント
最後は、アプリケーションのエントリポイント。
src/main/java/org/littlewings/spring/App.java
package org.littlewings.spring; import org.littlewings.spring.config.Config; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Import; @SpringBootApplication @Import(Config.class) public class App { public static void main(String... args) { SpringApplication.run(App.class, args); } }
こちらは、簡単に。
動作確認
それでは、動かしてみましょう。
パッケージング。
$ mvn package
2つNodeを起動してみます。
## Node 1 $ java -jar target/hibernate-search-infinispan-cache-0.0.1-SNAPSHOT.jar ## Node 2 $ java -jar target/hibernate-search-infinispan-cache-0.0.1-SNAPSHOT.jar --server.port=8180
Node 2のHTTPリッスンポートは、8180とします。
起動すれば、まずは成功…。前にやった時は、起動することもできませんでした…。
2つNodeを起動すると、途中のログでクラスタが構成されたことがわかります。
2015-05-30 23:15:25.619 INFO 128580 --- [ceneIndexesData] o.i.r.t.jgroups.JGroupsTransport : ISPN000094: Received new cluster view for channel cluster: [xxxxx-27108|1] (2) [xxxxx-27108, xxxxx-61235]
データ登録とHibernate Searchによる検索
それでは、データ登録や検索をしてみます。
$ time curl -XPUT 'http://localhost:8080/search/create?contents=Java+Programming+Language.' {"id":3,"contents":"Java Programming Language."} real 0m1.427s user 0m0.007s sys 0m0.000s $ time curl -XPUT 'http://localhost:8080/search/create?contents=Apache+Lucene+is+full-text+search+engine.' {"id":4,"contents":"Apache Lucene is full-text search engine."} real 0m0.215s user 0m0.008s sys 0m0.000s $ time curl -XPUT 'http://localhost:8080/search/create?contents=Spring+Boot.' {"id":5,"contents":"Spring Boot."} real 0m0.160s user 0m0.007s sys 0m0.000s
3つほど登録。
Node 2に対して、検索。
$ time curl 'http://localhost:8180/search/search?keyword=apache+lucene' [{"id":4,"contents":"Apache Lucene is full-text search engine."}] real 0m1.034s user 0m0.008s sys 0m0.004s
Node 1に対しても動くので、うまくインデックスが共有できているようです。
$ time curl 'http://localhost:8080/search/search?keyword=java' [{"id":3,"contents":"Java Programming Language."}] real 0m0.228s user 0m0.004s sys 0m0.004s
Cacheの確認
最後に、Cache機能を試してみます。
Node 1に対して、アクセス。
$ time curl 'http://localhost:8080/cache/add?a=3&b=5' 8 real 0m3.054s user 0m0.009s sys 0m0.000s
sleepが入っているので、3秒かかります。
この後、すぐにNode 2に対してアクセスすると、結果がすぐに得られます。
$ time curl 'http://localhost:8180/cache/add?a=3&b=5' 8 real 0m0.063s user 0m0.007s sys 0m0.004s
OKそうですね。
また、このCacheは10秒で有効期限切れするので、しばらく放っておくとまた時間がかかるようになります。Node 2へアクセス。
$ time curl 'http://localhost:8180/cache/add?a=3&b=5' 8 real 0m3.075s user 0m0.006s sys 0m0.004s
続いて、Node 1へ。
$ time curl 'http://localhost:8080/cache/add?a=3&b=5' 8 real 0m0.016s user 0m0.004s sys 0m0.004s
こちらにも反映されているようです。
Cacheのキーですが、デフォルトはメソッドの引数すべてのようなので、今回の実装方法だとリクエストのパラメータを変えると、それぞれ別のCacheエントリになります。
$ time curl 'http://localhost:8080/cache/add?a=3&b=5' 8 real 0m3.039s user 0m0.007s sys 0m0.005s $ time curl 'http://localhost:8080/cache/add?a=8&b=2' 10 real 0m3.031s user 0m0.009s sys 0m0.000s
とりあえず、やりたいことは実現できた感じです。