Go言語向け Elasticsearch クライアント Elastic の紹介とコントリビューション

  • このエントリーをはてなブックマークに追加

読了時間10分

白ヤギの開発者の森本です。
これは Elasticsearch Advent Calendar 2015 の6日目の記事です。

kamelio-elasticsearch-search2

今年もアドベントカレンダー流行っていますね!
例えば、Go言語だと Qiita のアドベントカレンダーには3つも登録されています。

気持ち的にはGo言語に関する記事も別途書きたいところで登録しようかを迷っていましたが、今回は Elasticsearch を選択しました。両方書けって話なんですが、時間的に余裕がなくてすみません (> <)

Elasticsearch クライアント

現時点ではGo言語向けの Elasticsearch の公式クライアントはありません。ですが、コミュニティベースのクライアントが Community Contributed Clients [Go] のページで3つ紹介されています。

本稿ではその中の1つである Elastic という名前のクライアントツールを紹介します。Elastic 社の社名とかぶるので Googlability がやばいですね。以降 Elastic と書いているものはこのクライントツールを指します。

それぞれの Elasticsearch のバージョンに対応する Elastic のバージョンがあります。Elastic のサイトからそのまま引用します。このバージョンも1つずれていたりします。まぁ仕方ないですね。

Elasticsearch version Elastic version Package URL
2.x 3.0 gopkg.in/olivere/elastic.v3
(source • docs • changelog)
1.x 2.0 gopkg.in/olivere/elastic.v2
(source • docs)
0.9-1.3 1.0 gopkg.in/olivere/elastic.v1
(source • docs)

 

雰囲気を掴むために、以下は Elastic の哲学 から内容を意訳したものです。この文章は Elastic の開発方針を表すと共に Elastic へコンテトリビュートするときに必要なことも端的に説明しています。

  1. Elasticsearch はとても多くの機能を提供していて、これを書いている時点で大雑把に 100 エンドポイントあり、600 もの JSON データ構造をもちます。Elastic は要求に応じて管理系よりも検索/インデクシングの機能を優先的に実装しています。要求に応じてとは言ったものの、Elastic は Elasticsearch のすべての機能を実装しないかもしれません。ごめんなさい。
  2. 私はプルリクエストが好きです。本当にそうですよ。但し、実装するときは先ず コントリビューションガイド を読んでください。API のエンドポイントを実装した上でテストも書いてください。
  3. Elasticsearch: The Definite Guide は Elastidcsearch 全般の入門に向いています。公式ドキュメント も細かいところは書いていませんが、それでもかなり良いものです。一般的な質問を尋ねる前にこれらのリンク先を調べてください。
  4. GitHub リポジトリ にあるテストは友だちです。Elastic の構文が分かりにくかったり何かの手段を理解しようとしているなら、そのテストを探してみてください。もしテストを見つけられなかったら気軽に issue 登録してください。
  5. 公式 JSON API 仕様 は友だちです。それを利用してください。疑問があれば、Java APIElasticsearch のソースコード を参考にしてください。REST API 仕様に基づいて API のエンドポイント向けに Go のコードを自動生成する generate-api ブランチ が Elastic にあります (100%自動というわけではありません) 。エンドポイントをコントリビュートしようとするときはそこから始めることになるでしょう。
  6. 私は 公式 Elasticsearch クライアント のベストプラクティスに従うようにしています。公式クライアントは堅牢な原則に基づいて開発されています。そういった詳細を実装してくれた方々に感謝します。

Elastic へのコントリビュート

私の環境では 1.7.3 のバージョンを使っています。2.0, 2.1 と早く試してみたいところですが、アプリのコードを書き換えないといけないのでまだそこまで手が回っていません。

つい先日 Term Vectors API を使う機能を実装しようとしたときに Elastic が対応していないことに気付きました。未対応なら作るしかないということで先に説明した哲学やコントリビューションガイドを読みました。その後、実際にプルリクエストを送ってみたところ、プルリクエストが好きだと書いてあるのに紛うことなく作者の Oliver 氏が丁寧にレビューしてくれました。

せっかくの機会だったのでその時のやり取りからコントリビュートする方法、さらには Elastic がどのように実装されているのかの一端について紹介します。

以下はその時のやり取りのプルリクエストです。

Go ソースコードの自動生成

哲学のところにも書かれていたように generate-api ブランチを使って Go のソースファイルを生成します。以下はディレクトリ構成です。

$ git clone git@github.com:olivere/elastic.git
$ git checkout -b generate-api origin/generate-api
$ cd generator/
$ tree .
.
├── Makefile
├── README.md
├── api.go
├── builder.go
└── rest-api-spec
    ├── bulk.json
    ├── cat.aliases.json
    ├── cat.allocation.json
     ...

オリジナルのリポジトリから持ってきた JSON の spec ファイルがたくさんあります。Makefile があるのでビルドしてみましょう。

$ make
rm -f *.gen.go
go generate -v && gofmt -w .
api.go
...
make: *** [gen] エラー 2

いくつかエラーになったりもするのですが、ここでは気にしないこととします。それぞれの JSON の spec に対応する api-name.gen.go というソースファイルが生成されるでしょう。このソースファイルをコピーしてきて新規追加したい API のソースを修正します。

$ tree .
.
├── Makefile
├── README.md
├── api.go
├── builder.go
├── bulk.gen.go
├── cat.aliases.gen.go
├── cat.allocation.gen.go
...

その前にこのソースコード生成の中身を簡単に追いかけてみましょう。api.go には go generate コマンドのコメントが記述されています。

$ vim api.go 
//go:generate go run builder.go -i=rest-api-spec/bulk.json
//go:generate go run builder.go -i=rest-api-spec/cat.aliases.json
//go:generate go run builder.go -i=rest-api-spec/cat.allocation.json
...
package main

これは Go 1.4 から追加された Go のソースコードを自動生成するときに使うツールです。

このコメントから実際には builder.go が実行されてソースコードを生成します。Elastic では elasticsearch/rest-api-spec にある JSON ファイルを使って Go のソースコードを自動生成します。これにより Oliver 氏の言葉では API 対応の 90% のコードを生成できるそうです。

builder.go の中身を追ってみると、JSON を読み込みながら Go のソースコードを出力する処理が淡々と実装されています。何と言うか文字列リテラルでソースコードのスニペットを書いていけばいいんだといった感じです。一部抜粋すると、こんな雰囲気です。

    ...
    pn("// Do executes the operation.")
    pn("func (s *%s) Do() (*%s, error) {",
        api.ServiceName(),
        api.ResponseTypeName(),
    )
    pn("\t// Check pre-conditions")
    pn("\tif err := s.Validate(); err != nil {")
    pn("\t\treturn nil, err")
    pn("\t}\n")

    pn("\t// Get URL for request")
    pn("\tpath, params, err := s.buildURL()")
    pn("\tif err != nil {")
    pn("\t\treturn nil, err")
    pn("\t}\n")
    ...

これはこれで分かりやすくて良いですね。こんな風に go generate を使うんだなと私は興味深かったです。

補足ですが、Go のコード生成に興味がある方は以下の記事が分かりやすかったです。

この記事では go generate はパッケージ利用者ではなく、パッケージ作者が使うツールだと説明されています。Make や他のビルド機構でやれることとの違いはないそうですが、go ツールとして go のエコシステムに組み込まれていることに意義があるようです。

$ go generate
$ go build
$ go test

使うときはこのようにコマンドを実行するので go でビルド処理が完結するため、揃っていて気持ち良い気はします。

自動生成したソースコードを修正する

自動生成したソースコードをみているとレスポンスオブジェクトの定義がありません。 termvector.json にレスポンスの定義がないからですね。

Elasticsearch のドキュメントを参照しながらレスポンスオブジェクトを追加します。

type TokenInfo struct {
    StartOffset int64  `json:"start_offset"`
    EndOffset   int64  `json:"end_offset"`
    Position    int64  `json:"position"`
    Payload     string `json:"payload"`
}

type TermsInfo struct {
    DocFreq  int64       `json:"doc_freq"`
    TermFreq int64       `json:"term_freq"`
    Ttf      int64       `json:"ttf"`
    Tokens   []TokenInfo `json:"tokens"`
}

type FieldStatistics struct {
    DocCount   int64 `json:"doc_count"`
    SumDocFreq int64 `json:"sum_doc_freq"`
    SumTtf     int64 `json:"sum_ttf"`
}

type TermVectorsFieldInfo struct {
    FieldStatistics FieldStatistics      `json:"field_statistics"`
    Terms           map[string]TermsInfo `json:"terms"`
}

// TermvectorResponse is the response of TermvectorService.Do.
type TermvectorResponse struct {
    Index       string                          `json:"_index"`
    Type        string                          `json:"_type"`
    Id          string                          `json:"_id,omitempty"`
    Version     int                             `json:"_version"`
    Found       bool                            `json:"found"`
    Took        int64                           `json:"took"`
    TermVectors map[string]TermVectorsFieldInfo `json:"term_vectors"`
}

当初 took というフィールドが Elasticsearch のドキュメントにはなかったので抜けていました。Oliver 氏からドキュメントには詳細が抜けていることもあるから TermVectorRequestBuilderTermVectorResponse のソースをみながら実装すると良いよと教えてもらいました。

90% が自動生成できるという言葉にあった通り、確かに2-3個のフィールドを手動で追加する必要がありました。とは言ってもほとんどのコードは自動生成されるので大変ではありません。API の追加作業をする上でどこを見てチェックすれば良いかという流れさえ把握できていればコントリビュートそのものの敷居は低いです。

テストを書く

テストもシンプルなものでしかありませんが、リクエストを送ってレスポンスが正常に返ってくるのを確認するテストを追加しました。

func TestTermVectorWithId(t *testing.T) {
    client := setupTestClientAndCreateIndex(t)

    tweet1 := tweet{User: "olivere", Message: "Welcome to Golang and Elasticsearch."}

    // Add a document
    indexResult, err := client.Index().
        Index(testIndexName).
        Type("tweet").
        Id("1").
        BodyJson(&tweet1).
        Refresh(true).
        Do()
    if err != nil {
        t.Fatal(err)
    }   
    if indexResult == nil {
        t.Errorf("expected result to be != nil; got: %v", indexResult)
    }   

    // TermVectors by specifying ID
    field := "Message"
    result, err := client.TermVector(testIndexName, "tweet").
        Id("1").
        Fields(field).
        FieldStatistics(true).
        TermStatistics(true).
        Do()
    if err != nil {
        t.Fatal(err)
    }   
    if result == nil {
        t.Fatal("expected to return information and statistics")
    }   
    if !result.Found {
        t.Errorf("expected found to be %v; got: %v", true, result.Found)
    }   
    if result.Took <= 0 { 
        t.Errorf("expected took in millis > 0; got: %v", result.Took)
    }   
}

さらに Term Vectors API は2つのエンドポイントを持つ API です。

  ...
  "url" : {
    "path" : "/{index}/{type}/_termvector",
    "paths" : ["/{index}/{type}/_termvector", "/{index}/{type}/{id}/_termvector"],
  ...

他のテストケースをみながら builder.buildURL() のテストも書いてくれと言われたのでそれも含めて3つのテストを書きました。他の API のテストコードがたくさんあるのでそれらを参考にしながらテストを書くのに困ることはありません。

ドキュメントを書く

ソースコードを生成するときに JSON spec ファイルの description を抽出してコメントも生成されます。Oliver 氏から英語として読めるように修正してくれと言われ、きょとんとしてしまいました。

Can you also fix the documentation here as well as in the other places? It should read as an English sentence.

他のソースコードをみても自動生成されたコメントをそのまま使っているようにみえるソースファイルも混在していました。どう直せばの良いのか、私の英語力がないために意図していることを理解できなくて最終的に彼に修正してもらうことになりました。その修正方法をみてこうすれば良かったんだというのを後になって把握しました。

例えば、自動生成されたものだと以下のようなコメントが付いています。

// Index is documented as: The index in which the document resides..
func (s *TermvectorService) Index(index string) *TermvectorService { 
	s.index = index
	return s
}

このコメントを以下のように修正してほしかったようです。

// Index in which the document resides.

補足として、godoc でドキュメントを扱うときコメントの最初の単語を関数名として扱うから、それらが一緒になるようにしないといけないと教えてもらいました。自分で godoc を作ったことがなく、そんな基本的なことも知らなくて教えてもらったりしました。

まとめ

そんな流れでだいたい3日ぐらいで無事プルリクエストをマージしてもらえました。やり取りを通していくつか知らなかったこともあり勉強になりました。

当初アドベントカレンダーに参加したときは Elastic の使い方の紹介を書こうと思っていました。1週間前の段階では CentOS7 と Elasticsearch 2.1 をインストールした環境も準備したりしていました。そうこうしていたら、たまたま数日前にプルリクエストを送ったことがきっかけとなり全然違う記事になってしまいました ^ ^;;

ここ最近、3ヶ月ほど私は Elastic を使うコードを書いてきました。ざっくり所感を述べると、Elastic でクエリを書くのは心地良いです。

1つの利点は Builder パターンでクエリを生成するので条件に応じた抽象化を実装しやすいです。Go 言語には型があるのでクエリの組み合わせができるものとそうでないものがコードを書きながら分かるため、間違った組み合わせで実装してしまう可能性が低いです。

また Elastic はオリジナルの JSON spec ファイルからソースコードを自動生成しているのを本稿で紹介しました。そのため、Elasticsearch のガイド/ドキュメントと Elastic の機能に乖離がありません (未対応はありますが) 。ドキュメントにあるクエリ DSL と同じ感覚で Elastic のクエリを実装できます。

つまり、このことがもう1つの利点として、クエリ DSL の学習コストがかかる Elasticsearch を扱う上でライブラリそのものの学習コストは低くなることに寄与すると言えます。Elastic の API もシンプルなため、2つか3つぐらいテストコードを見ながらクライアントコードを書けば大体の雰囲気を掴めると思います。この、既に学んだことを応用できるという感覚そのものが気持ち良いです。

なんか新しいこと覚えられない人間の言い訳っぽくなってきました。また次回 (いつになるか…) があれば、Elastic のサンプルコードも紹介したいと思います。

最先端情報吸収研究所 – AIAL

際限ない情報の中から、自分に価値のある情報を効果的に吸収することは、かつてなく大きなチャレンジです。最先端情報研究所はニュースアプリ「カメリオ」、レコメンドエンジン「カメクト」を提供する白ヤギコーポレーションのR&D部門として、データサイエンスの力でこの問題を解決していきます。白ヤギでは現在研究開発メンバーを募集しております。ご興味のある方は是非下記サイトを御覧ください!

Date:2015-12-06 Posted in:バックエンドの技術 Text by: