CLOVER🍀

That was when it all began.

Apache Solr 5.xのJSON Facet APIを、Solrjでちょっと強引に動かす

前にApache Solr 5.xでファセットを使ってみるエントリを書いたのですが、これをSolrjでやりたいと思いまして。

Apache Solr 5.xでファセットを試す - CLOVER

JSON Facet APIというのは、こちらで紹介されているAPIです。

Solr JSON Facet API

以前のファセットのAPIに比べて、相当速くなったのだとか。

で、前に書いたエントリのスキーマ定義、データなどは流用しつつ、コードはSolrjを使ってやってみましょう。

スキーマ定義とデータ

Solrのスキーマ定義は、このように。

    <field name="isbn" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="title" type="text_ja" indexed="true" stored="true" multiValued="false"/>
    <field name="price" type="int" indexed="true" stored="true" multiValued="false"/>
    <field name="publish_date" type="string" indexed="true" stored="true" multiValued="false"/>
    <field name="publish_year" type="string" indexed="true" stored="false" multiValued="false"/>
    <field name="author" type="text_ja" indexed="true" stored="true" multiValued="true"/>
    <field name="author_facet" type="string" indexed="true" stored="false" multiValued="true"/>
    <field name="tag" type="text_ja" indexed="true" stored="true" multiValued="true"/>
    <field name="tag_facet" type="string" indexed="true" stored="false" multiValued="true"/>

    <copyField source="publish_date" dest="publish_year" maxChars="4"/>
    <copyField source="author" dest="author_facet"/>
    <copyField source="tag" dest="tag_facet"/>

    <uniqueKey>isbn</uniqueKey>

登録するデータは、こんな感じ。
data.json

[
  {
    "isbn": "978-4774161631",
    "title": "[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
    "price": 3600,
    "publish_date": "20131129",
    "author": ["大谷 純", "阿部 慎一朗", "大須賀 稔", "北野 太郎", "鈴木 教嗣", "平賀 一昭", "株式会社リクルートテクノロジーズ"],
    "tag": ["Java", "Lucene", "Solr", "全文検索"]
  },
  {
    "isbn": "978-4048662024",
    "title": "高速スケーラブル検索エンジン ElasticSearch Server",
    "price": 2800,
    "publish_date": "20140321",
    "author": ["Rafal Kuc", "Marek Rogozinski", "大岩 達也", "大谷 純", "兼山 元太", "水戸 祐介", "守谷 純之介", "株式会社リクルートテクノロジーズ"],
    "tag": ["Java", "Lucene", "Elasticsearch", "全文検索"]
  },
  {
    "isbn": "978-4774127804",
    "title": "Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
    "price": 3200,
    "publish_date": "20060517",
    "author": [" 関口 宏司"],
    "tag": ["Java", "Lucene", "全文検索"]
  },
  {
    "isbn": "978-4774127804",
    "title": "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
    "price": 3200,
    "publish_date": "20060517",
    "author": ["Antonio Goncalves", "日本オラクル株式会社", "株式会社プロシステムエルオーシー"],
    "tag": ["Java", "Java EE"]
  },
  {
    "isbn": "978-4777518654",
    "title": "はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発",
    "price": 2500,
    "publish_date": "20141101",
    "author": ["槇 俊明"],
    "tag": ["Java", "Spring", "Spring Boot"]
  },
  {
    "isbn": "978-4798121963",
    "title": "エリック・エヴァンスのドメイン駆動設計",
    "price": 5200,
    "publish_date": "20110409",
    "author": ["エリック・エヴァンス", "今関 剛", "和智 右桂", "牧野 祐子"],
    "tag": ["DDD"]
  },
  {
    "isbn": "978-4798131610",
    "title": "実践ドメイン駆動設計",
    "price": 5200,
    "publish_date": "20150317",
    "author": ["ヴァーン・ヴァーノン", "高木 正弘"],
    "tag": ["DDD"]
  }
]

データ登録。

$ curl -H 'Content-Type: application/json' 'http://localhost:8983/solr/mycore/update?commit=true' --data-binary @data.json
{"responseHeader":{"status":0,"QTime":498}}

確認。

$ curl 'http://localhost:18983/solr/mycore/select?wt=json&indent=true' -d 'q=*:*'
{
  "responseHeader":{
    "status":0,
    "QTime":0,
    "params":{
      "q":"*:*",
      "indent":"true",
      "wt":"json"}},
  "response":{"numFound":6,"start":0,"docs":[
      {
        "isbn":"978-4774161631",
        "title":"[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
〜省略〜

OKですね。

準備

プログラムを書くための準備を。

Maven依存関係。

        <dependency>
            <groupId>org.apache.solr</groupId>
            <artifactId>solr-solrj</artifactId>
            <version>5.3.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>jcl-over-slf4j</artifactId>
            <version>1.7.12</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.12</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.6.3</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.2.0</version>
            <scope>test</scope>
        </dependency>

SLF4Jは、Solrj関係で入れています。あと、JacksonはJSON Facet APIのために入れました。こちらは、また後で。

JUnitとAssertJが入っているのは、テストコード用ですね。

テストコード

テストコードの雛形は、こちら。
src/test/java/org/littlewings/solrj/NewFacetApiTest.java

package org.littlewings.solrj;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.HttpSolrClient;
import org.apache.solr.client.solrj.request.QueryRequest;
import org.apache.solr.client.solrj.response.FacetField;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.common.util.NamedList;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class NewFacetApiTest {
    // ここに、テストを書く!
}
以前のファセットAPIを使ってみる

いきなりJSON Facet APIを使うのではなく、とりあえず既存のファセットAPIを使ってみましょう。

curlでいくと、こういうリクエストですね。

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true&facet=true&facet.sort=index&facet.field=tag_facet' -d '{ "query": "*:*" }'

ファセット部分のレスポンスは、こちらです。

  "facet_counts":{
    "facet_queries":{},
    "facet_fields":{
      "tag_facet":[
        "DDD",2,
        "Elasticsearch",1,
        "Java",4,
        "Java EE",1,
        "Lucene",2,
        "Solr",1,
        "Spring",1,
        "Spring Boot",1,
        "全文検索",2]},
    "facet_dates":{},
    "facet_ranges":{},
    "facet_intervals":{},
    "facet_heatmaps":{}}}

これを、Solrjを使って書くとこのようになります。

    @Test
    public void testNormalFacetApi() throws IOException, SolrServerException {
        try (SolrClient solrClient = new HttpSolrClient("http://localhost:8983/solr/mycore")) {
            SolrQuery solrQuery = new SolrQuery();

            // query
            solrQuery.setQuery("*:*");

            // facet
            solrQuery.setFacet(true);
            solrQuery.addFacetField("tag_facet");
            solrQuery.setFacetSort("index");

            QueryResponse queryResponse = solrClient.query(solrQuery);

            assertThat(queryResponse.getStatus())
                    .isEqualTo(0);
            assertThat(queryResponse.getFacetField("tag_facet").getValueCount())
                    .isEqualTo(9);

            List<FacetField.Count> facetValues = queryResponse.getFacetField("tag_facet").getValues();
            assertThat(facetValues.get(0).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(0).getName()).isEqualTo("DDD");
            assertThat(facetValues.get(0).getCount()).isEqualTo(2);
            assertThat(facetValues.get(1).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(1).getName()).isEqualTo("Elasticsearch");
            assertThat(facetValues.get(1).getCount()).isEqualTo(1);
            assertThat(facetValues.get(2).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(2).getName()).isEqualTo("Java");
            assertThat(facetValues.get(2).getCount()).isEqualTo(4);
            assertThat(facetValues.get(3).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(3).getName()).isEqualTo("Java EE");
            assertThat(facetValues.get(3).getCount()).isEqualTo(1);
            assertThat(facetValues.get(4).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(4).getName()).isEqualTo("Lucene");
            assertThat(facetValues.get(4).getCount()).isEqualTo(2);
            assertThat(facetValues.get(5).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(5).getName()).isEqualTo("Solr");
            assertThat(facetValues.get(5).getCount()).isEqualTo(1);
            assertThat(facetValues.get(5).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(6).getName()).isEqualTo("Spring");
            assertThat(facetValues.get(6).getCount()).isEqualTo(1);
            assertThat(facetValues.get(7).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(7).getName()).isEqualTo("Spring Boot");
            assertThat(facetValues.get(7).getCount()).isEqualTo(1);
            assertThat(facetValues.get(8).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(8).getName()).isEqualTo("全文検索");
            assertThat(facetValues.get(8).getCount()).isEqualTo(2);
        }
    }

最後に結果を確認しているのでいろいろ長いですが、リクエスト作ってを投げているのはこの部分ですね。

        try (SolrClient solrClient = new HttpSolrClient("http://localhost:8983/solr/mycore")) {
            SolrQuery solrQuery = new SolrQuery();

            // query
            solrQuery.setQuery("*:*");

            // facet
            solrQuery.setFacet(true);
            solrQuery.addFacetField("tag_facet");
            solrQuery.setFacetSort("index");

            QueryResponse queryResponse = solrClient.query(solrQuery);

あとは、レスポンスからファセットの内容を読んでいきます。

            assertThat(queryResponse.getFacetField("tag_facet").getValueCount())
                    .isEqualTo(9);

            List<FacetField.Count> facetValues = queryResponse.getFacetField("tag_facet").getValues();
            assertThat(facetValues.get(0).getFacetField().getName()).isEqualTo("tag_facet");
            assertThat(facetValues.get(0).getName()).isEqualTo("DDD");
            assertThat(facetValues.get(0).getCount()).isEqualTo(2);

こちらはOKそうですね。

JSON Facet APIを使う

続いて、JSON Facet APIを使ってみます。

curlでいくと、こういうリクエストになります。

$ curl 'http://localhost:18983/solr/mycore/select?wt=json&indent=true' -d 'q=*:*&json.facet={ "tags": { "type": "terms", "field": "tag_facet" } }'

ファセットの部分のレスポンスは、このようになります。

  "facets":{
    "count":6,
    "tags":{
      "buckets":[{
          "val":"Java",
          "count":4},
        {
          "val":"DDD",
          "count":2},
        {
          "val":"Lucene",
          "count":2},
        {
          "val":"全文検索",
          "count":2},
        {
          "val":"Elasticsearch",
          "count":1},
        {
          "val":"Java EE",
          "count":1},
        {
          "val":"Solr",
          "count":1},
        {
          "val":"Spring",
          "count":1},
        {
          "val":"Spring Boot",
          "count":1}]}}}

ところで、ファセットの「count」は既存のAPIと異なり、ヒットしたドキュメント数っぽいですね。既存のAPIだと、ファセットのエントリ数っぽいのですが。

で、これをSolrjでやりたいと思い、APIを見ます。

Solr 5.3.1 API

しかしですね、それっぽいAPIないんですよ。

最新のソースコードを見ると、JSON Facet APIに関するクラスが追加されているようですが、少なくともSolr 5.3.1にはなさそうです。

https://github.com/apache/lucene-solr/blob/trunk/solr/solrj/src/java/org/apache/solr/client/solrj/io/stream/FacetStream.java

仕方がありません、とりあえず強引にでもリクエストを通す方法を考えてみます。

要するに「json.facet」をPOSTできればよいので、そのように考えます。

ファセット用のクラスを用意。
src/test/java/org/littlewings/solrj/Facet.java

package org.littlewings.solrj;

public class Facet {
    private String type;
    private String field;
    private String sort;

    Facet(String type, String field, String sort) {
        this.type = type;
        this.field = field;
        this.sort = sort;
    }

    public static Facet create(String type, String field) {
        return create(type, field, null);
    }

    public static Facet create(String type, String field, String sort) {
        return new Facet(type,field, sort);
    }

    public String getType() {
        return type;
    }

    public String getField() {
        return field;
    }

    public String getSort() {
        return sort;
    }
}

json.facet」の中身に対応します。とりあえず、今回はRangeやQueryを使ったファセットについては考えないことにします。

で、テストコードに簡易的なオブジェクト⇒JSON変換クラスを用意。

    private String toJson(Object target) throws JsonProcessingException {
        ObjectMapper objectMapper =
                new ObjectMapper()
                        .configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false)
                        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                        .setSerializationInclusion(JsonInclude.Include.NON_EMPTY);

        return objectMapper.writeValueAsString(target);
    }

これらを使用して、できあがったテストコードがこちら。

    @Test
    public void testNewFacetApi() throws IOException, SolrServerException {
        try (SolrClient solrClient = new HttpSolrClient("http://localhost:8983/solr/mycore")) {
            SolrQuery solrQuery = new SolrQuery();

            // query
            solrQuery.setQuery("*:*");

            // json facet
            Map<String, Object> jsonFacetMap = new LinkedHashMap<>();
            jsonFacetMap.put("tags", Facet.create("terms", "tag_facet", "index asc"));

            solrQuery.add("json.facet", toJson(jsonFacetMap));

            NamedList<Object> queryResponse =
                    solrClient.request(new QueryRequest(solrQuery, SolrRequest.METHOD.POST));
            NamedList<Object> facetResponse = (NamedList<Object>) queryResponse.get("facets");

            assertThat(facetResponse.get("count"))
                    .isOfAnyClassIn(Integer.class);

            assertThat((Integer) facetResponse.get("count"))
                    .isEqualTo(6);

            List<NamedList<Object>> buckets =
                    (List<NamedList<Object>>) ((NamedList<Object>) facetResponse.get("tags")).get("buckets");

            assertThat(buckets.get(0).get("val")).isEqualTo("DDD");
            assertThat((Integer) buckets.get(0).get("count")).isEqualTo(2);
            assertThat(buckets.get(1).get("val")).isEqualTo("Elasticsearch");
            assertThat((Integer) buckets.get(1).get("count")).isEqualTo(1);
            assertThat(buckets.get(2).get("val")).isEqualTo("Java");
            assertThat((Integer) buckets.get(2).get("count")).isEqualTo(4);
            assertThat(buckets.get(3).get("val")).isEqualTo("Java EE");
            assertThat((Integer) buckets.get(3).get("count")).isEqualTo(1);
            assertThat(buckets.get(4).get("val")).isEqualTo("Lucene");
            assertThat((Integer) buckets.get(4).get("count")).isEqualTo(2);
            assertThat(buckets.get(5).get("val")).isEqualTo("Solr");
            assertThat((Integer) buckets.get(5).get("count")).isEqualTo(1);
            assertThat(buckets.get(6).get("val")).isEqualTo("Spring");
            assertThat((Integer) buckets.get(6).get("count")).isEqualTo(1);
            assertThat(buckets.get(7).get("val")).isEqualTo("Spring Boot");
            assertThat((Integer) buckets.get(7).get("count")).isEqualTo(1);
            assertThat(buckets.get(8).get("val")).isEqualTo("全文検索");
            assertThat((Integer)
                    buckets.get(8).get("count")).isEqualTo(2);
        }
    }

だいぶえらいことになっていますが、現時点だとこんな感じなんでしょうか…。

json.facet」に対するMapを作成し、ファセットに対応するエントリ(ここでは、Facetクラスで作っていますが)を登録します。

        try (SolrClient solrClient = new HttpSolrClient("http://localhost:8983/solr/mycore")) {
            SolrQuery solrQuery = new SolrQuery();

            // query
            solrQuery.setQuery("*:*");

            // json facet
            Map<String, Object> jsonFacetMap = new LinkedHashMap<>();
            jsonFacetMap.put("tags", Facet.create("terms", "tag_facet", "index asc"));

そして、JSON変換。

            solrQuery.add("json.facet", toJson(jsonFacetMap));

リクエストのメソッドをPOSTにして、クエリを投げます。

            NamedList<Object> queryResponse =
                    solrClient.request(new QueryRequest(solrQuery, SolrRequest.METHOD.POST));

あとは「facet」を取得して、その中から「count」や指定したファセットの単位(ここでは「tags」)、そして「buckets」を取得していきます。

            NamedList<Object> facetResponse = (NamedList<Object>) queryResponse.get("facets");

            assertThat(facetResponse.get("count"))
                    .isOfAnyClassIn(Integer.class);

            assertThat((Integer) facetResponse.get("count"))
                    .isEqualTo(6);

            List<NamedList<Object>> buckets =
                    (List<NamedList<Object>>) ((NamedList<Object>) facetResponse.get("tags")).get("buckets");

で、結果確認、と。

            assertThat(buckets.get(0).get("val")).isEqualTo("DDD");
            assertThat((Integer) buckets.get(0).get("count")).isEqualTo(2);
            assertThat(buckets.get(1).get("val")).isEqualTo("Elasticsearch");
            assertThat((Integer) buckets.get(1).get("count")).isEqualTo(1);

とりあえず、動いた感じですね…。

ちょっと強引なので、これでいいのかなーという気もしますが、現時点でのSolrjでJSON Facet APIを使う場合はこんな感じになるんでしょうか…。