GAE/Go で Search API を使ってみよう！

こんにちは @wezardnet です。社内では Google Cloud Platform(GCP) について語れる人、相談できる人がいなくて寂しい今日このごろです... :sob:

私事ですがサーバーサイドは Java → Go に今後シフトしていくので、これまでの GAE/Java で経験してきたことを Go でも実装できるように日々鍛錬中です。

Datastore や Task Queue など Google App Engine(GAE) の基本機能は他に多くの記事が書かれているので参考にすることができるのですが [Search API](https://cloud.google.com/appengine/docs/standard/go/search/) に関しては少なく、特に Go 言語ではあまり見かけなかったので、備忘録もかねて書こうと思います。

Search API の基本的な機能を試す用に次のような Web ページを作りました。できることは入力、一覧表示、検索、（削除）だけです :sweat_smile:

![App Engine Search API.png](https://qiita-image-store.s3.amazonaws.com/0/12023/ea6a1cb4-eee0-f2e3-7495-c0da6fa6145a.png)



#1. Search API ってなによ？

端的に言うと GAE で利用できる全文検索を行う機能になります。昔は Full-Text Search(FTS) と呼ばれてました。略すとプロレスの技みたいな名前ですねｗ
なぜこのような機能が単体で存在するかといえば GAE には Datastore というデータ量に応じてスケールする強力なデータベースが用意されていますが、実際に使ったことがある人なら知ってると思いますが、コイツには少々クセがあり複雑な検索には向いていません。（設計に関して相当な経験や知見がないと難しいです）

* [Google App Engine を通してアプリケーション・インフラを考える](http://engineer.crowdworks.jp/entry/2016/06/03/122538)

機能要件に「複合条件による検索」はよく入ってくるので、開発者から GAE に対してリクエストが多く Search API として提供されました。が、残念なことに Datastore に対して実装されたのではなく、別のデータベースになっています。この機能は正式にリリースされています。

Search API については、以下の解説記事を読むと良いでしょう。かなり詳細に調査され、まとめられているので、コスト面も含めて採用するかどうか判断の際に参考になると思います。

* [Search API詳細解説 Part1「Search API 概要説明」](http://www.apps-gcp.com/seach-api-part1/)

検索機能に重点を置くのであれば、今なら Cloud SQL や Cloud Spanner など選択肢は多いので迷うところではありますが、、、



#2. コード書きます

それではコードを書いていきます。特にハマるところはないのですが、私は検索結果のスニペット（強調表示）が Java のように取得できずに少々苦労しました。スニペットは HTML で返され、検索結果を表示するときに次のように検索キーワードをハイライトさせることができます。

![snippet.png](https://qiita-image-store.s3.amazonaws.com/0/12023/94c4bb60-bfd5-64d8-19b5-6b0ebfed1658.png)

HTML で返ってくるスニペットですが、単純にボールドタグが付いてるだけですので、蛍光マーカーのようにするにはネットからパクった CSS を使って表示させますｗ

```css
.marker_yellow b {
    background: linear-gradient(transparent 0%, #ffff66 0%);
}
```

それでは必要なパッケージをインポートします。尚、コードは見づらくなるので一部省略してます。

```Go:Import
import (
	"fmt"
	"io/ioutil"
	"strconv"
	"time"

	"github.com/bitly/go-simplejson"
	"github.com/satori/go.uuid"

	"google.golang.org/appengine"
	"google.golang.org/appengine/log"
	"google.golang.org/appengine/search"
)
```

次にデータベースのスキーマを定義します。今回はサンプルなので、ニュース記事と記事の公開日、検索結果のスニペット（データは格納しません）を持つ Fulltext を定義します。

```Go:Import
type Fulltext struct {
	Document       string
	Published      time.Time
	Snippet        search.HTML
}
```

RDBMS でいうところの列（カラム）は Search API では **フィールド** と呼びますが、そのデータ型は以下が利用できます。

* string
* time.Time
* float64
* search.HTML
* search.Atom
* appengine.GeoPoint


##2.1 登録

Search API を使うためには、まずはじめに RDBMS で言うところの表（テーブル）をオープンします。これは Search API では **インデックス** と呼ばれます。
クライアントから POST された JSON データを受け取り、ドキュメントの登録を行います。

```Go:Create
ctx := appengine.NewContext(r)

index, err := search.Open("fulltext")

body, _ := ioutil.ReadAll(r.Body)
data, _ := simplejson.NewJson(body)

fullText := &Fulltext {
	Document:  data.Get("doc").MustString(), 
	Published: time.Now(), 
}

_, err = index.Put(ctx, uuid.NewV4().String(), fullText)
if err != nil {
	log.Errorf(ctx, "err = %s", err.Error())
	return
}
```

次に RDBMS で言うところの行（レコード）の登録を行います。これは Search API では **ドキュメント** と呼ばれますが、一意となるドキュメント識別子（docID）を生成して登録する格好になります。今回は UUID を用いましたが、実際には Datastore と組み合わせる形になることが想定されるので Datastore のキーを利用することになるかと思います。


##2.2 一覧表示

データベースに格納されたドキュメントの一覧を取得します。実際には使う場面は少ないと思いますが、一応載せておきます。

```Go:List
ctx := appengine.NewContext(r)

index, err := search.Open("fulltext")

var list []interface{}
for t := index.List(ctx, nil); ; {
	var fullText Fulltext
	id, err := t.Next(&fullText)
	if err == search.Done {
		break
	}
	if err != nil {
		log.Errorf(ctx, "err = %s", err.Error())
		return
	}
	list = append(list, map[string]interface{} {
		"id": id, 
		"document": fullText.Document, 
		"published": fullText.Published, 
	})
}
```

ちなみに登録されているドキュメントの一覧は [Google Developer Console](https://console.cloud.google.com/) の App Engine → Search からでも参照できるようになっています。残念なのは、この画面からドキュメントを削除できないことですかねぇ。。。

![fulltext.png](https://qiita-image-store.s3.amazonaws.com/0/12023/c99c1865-0c4e-9504-1b53-525c366cbcc4.png)


##2.3 検索

メインになる検索です。クライアントから検索キーワード（word）と表示件数（limit）を受け取ります。ソートは公開日（Published）の降順にしてます。

```Go:Search
ctx := appengine.NewContext(r)

index, err := search.Open("fulltext")

query := "Document=" + r.FormValue("word")
limit, _ := strconv.Atoi(r.FormValue("limit"))

options := search.SearchOptions {
	Limit: limit, 
	Sort: &search.SortOptions {
		Expressions: []search.SortExpression {
			{Expr: "Published", Reverse: false}, 
		}, 
	}, 
	Expressions: []search.FieldExpression {
		{Name: "Snippet", Expr: fmt.Sprintf(`snippet("%s", Document)`, query)}, 
	}, 
}

var list []interface{}
for t := index.Search(ctx, query, &options); ; {
	var fullText Fulltext
	id, err := t.Next(&fullText)
	if err == search.Done {
		break
	}
	if err != nil {
		log.Errorf(ctx, "err = %s", err.Error())
		return
	}

	list = append(list, map[string]interface{} {
		"id": id, 
		"document": fullText.Document, 
		"published": fullText.Published, 
		"snippet": fullText.Snippet, 
	})
}
```

スニペットは SearchOptions の Expressions で FieldExpression の中で指定してやります。ココが分かり難いのですが snippet 関数の第一引数の query には、対象のフィールド名を含まないと **Internal Server Error** が返ってきます。第二引数で指定しているので不要かと思っていたのですがハマりました。たとえば「クラウド」で検索した場合は「snippet("Document=クラウド", Document)」となります。また加えて、最初の構造体（struct）の定義にも、スニペット用のフィールドを用意しておく必要があります。


##2.4 カーソルの使い方

一度に返される検索結果は SearchOptions の Limit で指定した件数になります。デフォルトは 20 件で最大 1,000 件まで指定できます。ページングする場合は Cursor を使って取得していく格好になります。

```Go:Cursor
ctx := appengine.NewContext(r)

index, err := search.Open("fulltext")

query := "Document=" + r.FormValue("word")
limit, _ := strconv.Atoi(r.FormValue("limit"))

options := search.SearchOptions {
	Limit: limit, 
	Sort: &search.SortOptions {
		Expressions: []search.SortExpression {
			{Expr: "Published", Reverse: false}, 
		}, 
	}, 
	Expressions: []search.FieldExpression {
		{Name: "Snippet", Expr: fmt.Sprintf(`snippet("%s", Document)`, query)}, 
	}, 
	Cursor: search.Cursor("CqoFCvQCCtEC_wDAQNDewAD_AP8A_wD_nYGRiJCNlIzSno-PjP8AAP90baCgmYuMoKD_AAD_XZ6Pj5qRmJaRmv8AAP9zdG2WkZuah_8AAP9dmYqTk4uah4v_AAD_c3Rtm5CcoJab_wAA_12axp6cnJrLx9LOnsfP0svOnpvSnc-emtKax8bJzpqZycecmcr_AAD_c3-axp6cnJrLx9LOnsfP0svOnpvSnc-emtKax8bJzpqZycecmcr_AAD__wD-__7_nYGRiJCNlIzSno-PjP8AdG2goJmLjKCg_wBdno-PmpGYlpGa_wBzdG2WkZuah_8AXZmKk5OLmoeL_wBzdG2bkJyglpv_AF2axp6cnJrLx9LOnsfP0svOnpvSnc-emtKax8bJzpqZycecmcr_AHN_msaenJyay8fSzp7Hz9LLzp6b0p3PnprSmsfGyc6amcnHnJnK_wD__hABIcKa1MW-fKyxUABaCwmcySrk1kaoohACYOOAuYUDEg1Eb2N1bWVudEluZGV4GsgBKEFORCAoSVMgImN1c3RvbWVyX25hbWUiICJhcHBlbmdpbmUiKSAoSVMgImdyb3VwX25hbWUiICJifm53b3Jrcy1hcHBzIikgKElTICJuYW1lc3BhY2UiICIiKSAoSVMgImluZGV4X25hbWUiICJmdWxsdGV4dCIpIChPUiAoSVMgInJhdG9tX0RvY3VtZW50IiAi44Kv44Op44Km44OJIikgKFFUICLjgq_jg6njgqbjg4kiICJydGV4dF9Eb2N1bWVudCIpKSk6IgoVKE4gInNkYXRlX1B1Ymxpc2hlZCIpEAEZAAAAAAAAAABKGAgAOhJidGlfZ2VuZXJpY19zY29yZXJAAVIZCgwoTiBvcmRlcl9pZCkQARkAAAAAAADw_w"), 
}

（省略）

```

Cursor に指定する文字列は、検索結果のイテレータに含まれているので、それを取得してセットしてあげます。コードは前述した”2.3. 検索”と同じになるので省略しましたが、全件取得する場合はカーソルがなくなるまで回せば良いでしょう。


##2.5 削除

前述しましたが、ドキュメントの削除は [Google Developer Console](https://console.cloud.google.com/) からできないので、自分で実装するようにしましょう。

```Go:Delete
ctx := appengine.NewContext(r)

index, err := search.Open("fulltext")

err := index.Delete(ctx, r.FormValue("id"))
if err != nil {
	log.Errorf(ctx, "err = %s", err.Error())
	return
}
```

#3. おわりに、、、

実際にコードを書いてみると Java と Go では書き方が微妙に違うので、意外と手間取ります。なんか Java ではこうだから、こんな感じで良いはずなんだけど、、、あれぇーって感じで苦戦します。
ビルドエラーならまだしも **Internal Server Error** だけで返されると、どこに原因があるのか特定し辛いッス :disappointed_relieved: