44
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

天網恢恢疎にして漏らさず全文検索 ~firestore + Elasticsearch + react native~

Last updated at Posted at 2018-05-19

前書き

firestoreのクエリが使いにくかったので、Elasticsearchでデータを全文検索する方法を調べました。
realtime databaseを使うのであれば、参考になる記事がいくつか見つかっていたのですが、
firestoreを使う場合に良さそうな記事に出会えず、
結局、自分でrealtime database向けの実装を参考にしながらfirestore用に書き換える苦労があったので、
残しておきます。
firebaseを使うと、バックエンドを多少意識から外してもサービスが作れるようになってきている気がしていますが、
まだまだやり方が確立されていないので、どんどん試して公開していく人が増えていくと良いなと思っています。
リクエストがあれば、頑張ります。

対象

  • firebase使いたいなと思ってる人
  • firestoreのクエリに物足りなさを感じてる人
  • Elasticsearchを使って見たい人
  • 気軽にネイティブのアプリケーションを作ってみたい人

やったこと概要

説明

node

ここにコード自体は置いてあるので、説明不要な人はcloneして使ってください。
本家のコードを見たければ、こちらをどうぞ。
注意点としては、本家の方はrealtime databaseのためのコードなので、微妙に使い勝手が違います。
realtime databaseからfirestoreに乗り換える時に、一番厄介に感じたのは、
データベースのエンドポイントを直接叩けるかどうか、ということでした。
firebaseのデータベースに保存してあるデータが更新された時に、Elasticsearchにもデータの更新を伝える場合、
realtime databaseなら直接urlを叩けば行けたのですが、firestoreは自分で書き換える必要がありました。

分かってしまえば非常に簡単で、firestoreをsubscribeして変更があった時点で処理を掛けていくだけです。

cloud functionsでやれば、onCreate, onDeleteなどをトリガーにして発火できたので、そっちの方が良かったかもしれないですが、まぁ一旦は運用でカバー

それでは中身の説明に移ります。
ディレクトリ構造はこんな感じです。

.
├── app.js  # 本体コード
├── app.yaml  # gaeにdeploy用の設定
├── config.example.js  # config
├── lib
│   ├── Registration.js  # firestoreに変更があったときに、Elasticsearchにupdateをかけにいくコード
│   ├── Search.js  # 検索クエリを投げられたときに。Elasticsearchに検索をかけ、結果を返すコード
│   └── initFirebase.js  # firebaseのsdkを初期化するコード
├── package-lock.json
└── package.json

1. sdkの初期化

まずは、firebaseのsdkをいつも通り初期化します。

const conf = require('../config');
const firebase = require('firebase');

const firebase_config = {
    apiKey: conf.apiKey,
    authDomain: conf.authDomain,
    databaseURL: conf.databaseURL,
    projectId: conf.projectId,
    storageBucket: conf.storageBucket,
    messagingSenderId: conf.messagingSenderId
};
firebase.initializeApp(firebase_config);

たまに忘れます

2. 検索機能

次に検索機能の実装です。
まずは、firestoreにsearch_request, search_responseというcollectionを作成します。
search_requestがネイティブのアプリからElasticsearchへと渡された検索条件を受け取るcollectionで、search_responseがネイティブのアプリに渡すための検索結果を入れるcollectionです。
イメージはこの記事を読んでもらうとわかりやすいかと思います。
記事全ては読まなくても、概念図としてA little bit trickyと書いてある部分の程度は見ておいた方が、
後々理解の助けになるかと思います。
記事中ではcloud functionとrealtime databaseを使っていますが、
今回の実装では、cloud functionの代わりに、GAEでnodeを立てていて、
realtime databaseの代わりにfirestoreを使っていますが、やり方自体は大差がないです。

firebaseでは、realtime databaseやfirestoreのデータに変更が加えられたというイベントをトリガーにして
関数を発火させることができます。

this.ref_req = firebase.firestore().collection('search_request');
this.unsubscribe = this.ref_req.onSnapshot(this._showResults.bind(this));

firestoreでは、collectionを監視対象にして、collection全体で変更があったタイミングで
関数を発火させていますが、documentを監視対象にすることも出来ます。
collectionを監視対象にして、飛ばしたrequestに対応するresponseの有無を調べて、
該当するデータがあればreact側に拾ってくる処理をしても良いのですが、
セキュリティ的にも、ネットワークの通信料にしても、データベースの運用に関しても筋が悪そうなので、
responseを返す時には、documentだけをsubscribeします。

検索requestをsubscribeし、Elasticsearchに検索を投げ、結果をresponseに返すのは次のように実装できます。

'use strict';

const firebase = require('firebase');
const _ = require('lodash');
//////////////////////////////////////////////////
/*
 * firebaseのデータにqueryが登録されたときに
 * ElasticSearchへ検索を投げて、結果をfirebaseの方に返す
 * 返却するときのデータ整形もあとで決める
*/

class Search {

    constructor(esc, refReq, refRes, index, type) {
        this.esc = esc;
        this.refReq = refReq;
        this.refRes = refRes;
        this.index = index;
        this.type = type;
    }

    init() {
        /*
         * subscribeをcollectionに張る
        */
        this.refReq = firebase.firestore().collection(this.refReq);
        this.refRes = firebase.firestore().collection(this.refRes);
        this.unsubscribe = null;
        this.unsubscribe = this.refReq.onSnapshot(this._showResults.bind(this));
    }

    _showResults(snap) {
        /*
         * firestoreに投げられた検索リクエストを取得して、Elasticsearchに渡すクエリに変換する
        */
        snap.forEach((doc) => {
            let { from, q, size } = doc.data();
            let query = {
                from,
                index: this.index,
                q,
                size,
                type: this.type,
            }
            this._searchWithElasticsearch(doc, query)
        })
    }

    _searchWithElasticsearch(doc, query) {
        /*
         * elasticsearchで検索を行い、
         * 結果のデータを整形してfirestoreに受けわたす関数
        */
        this.esc.search(query, function(error, response) {
            if(_.isUndefined(error)){
                let returnData = {}
                response.hits.hits.forEach((data) => {
                    returnData[data._id] = {
                        id: data._id,
                        source: data._source,
                        score: data._score,
                    }  // まだfirestoreのどのデータをelasticsearchに送るのか決めてないので、返ってきたものを全てfirestoreに受け渡している
                })
                console.log('_searchWithElasticsearch: success', query, returnData)
                this.refRes.doc(doc.id).set(returnData);
                this.refReq.doc(doc.id).delete();
            }else{
                console.log('_searchWithElasticsearch: failed', error)
            }
        }.bind(this));
    }

}

exports.init = function(esc, refReq, refRes, index, type) {
    new Search(esc, refReq, refRes, index, type).init();
}

3. firestoreのデータとElasticsearchの同期

最後に、firestoreのデータが更新された時に、Elasticsearchへ更新をかけにいく部分の実装です。
データが削除された時のことは、一旦考えないです。
まずfirestoreにusersというcollectionを作ります。
変更があったタイミングでElasticsearchに更新をかけに行きたいわけですが、
自分が触っていた段階では、更新されたdocumentだけを更新された回数だけElasticsearchに更新しにいくというのが少々難しかったので、フラグで管理してみました。
具体的には、reactで更新する時に、ES_STATEというfieldを用意して、そこをSTAYなど任意のキーワードに書き換えておきます。
そして、STAYになっているdocumentだけをsubscribeしておき、
collectionに変更があった段階で、STAYのdocumentを全て拾ってきてElasticsearchに更新をかけます。
その後で、firestoreで管理しているフラグを倒しに行きます。

'use strict';

const firebase = require('firebase');
const _ = require('lodash');
//////////////////////////////////////////////////
/*
 * firebaseのデータに変更が加えられたときに、
 * ElasticSearchの方にデータを送る
*/

class Registration {

    constructor(esc, collection, index, type) {
        this.esc = esc;
        this.collection = collection;
        this.index = index;
        this.type = type;
    }

    init() {
        this.ref = firebase.firestore().collection(this.collection).where('ES_STATE', '==', 'STAY');
        this.unsubscribe = null;
        this.unsubscribe = this.ref.onSnapshot(this._showResults.bind(this));
    }

    _showResults(snap) {
        snap.forEach((doc) => {
            const sendData = {
                index: this.index,
                type: this.type,
                id: doc.id,
                body: {
                    name: doc.data().name,
                    text: doc.data().text,
                    updatedAt: doc.data().updatedAt,
                    createdAt: doc.data().createdAt,
                },  // bodyに何を送るかは別途考える。index, type, collectionはclassの外で定義出来るようにしたので、これも切り出したい
            }
            this._sendDataToElasticsearch(sendData)
            // 返り値を用意して、firestoreのフラグ変更の関数の発火を制御した方が良いかもしれない
            this._updateFlagInFirestore(doc)
        })
    }

    _sendDataToElasticsearch(sendData) {
        /*
         * firestoreで変更されたデータをElasticsearchに送信する関数
        */
        this.esc.index(sendData, function (error, response) {
            if(_.isUndefined(error)){
                console.log('_sendDataToElasticsearch: success')
            }else{
                console.log('_sendDataToElasticsearch: failed', error)
            }
        }.bind(this));
    }

    _updateFlagInFirestore(doc) {
        /*
         * firestoreの各ドキュメントにステータス管理用のfieldを用意している。
         * Elasticsearchにデータを更新し終わったら、firestoreのステータス管理fieldに
         * ステータス変更を書き込みに行く関数
        */
        return firebase.firestore().collection(this.collection).doc(doc.id).update({
            'ES_STATE': 'DONE',
        })
        .then(function() {
            console.log('_updateFlagInFirestore: success');
        })
        .catch(function(error) {
            // The document probably doesn't exist.
            console.error('_updateFlagInFirestore: failed', error);
        });
    }

}

exports.init = function(esc, collection, index, type) {
    new Registration(esc, collection, index, type).init();
}

実際は、渡されたクエリはバリデーションを通さないといけないとか、読み書きに失敗した時の例外処理をどうするのか等、色々ありますが、一旦はなしです。
作りたいサービスが何か出来れば、もう少し考えて行きます。
また、今回は単純な検索クエリを作っただけですが、凝ったことがしたければ誤字脱字対応など色々できます。

GAE

これで、

node app.js

としても良いんですが、GAEにdeployします。
簡単なバックエンドのサービスだけなら、無料枠で十分収まるし、スケールなども考えなくて良いので
GAEは良さそうです。
フロントはネイティブアプリでバックエンドはGAEでデータベースはfirebaseを使うと、
自分たちで管理する必要があるのはコードだけになるので、少人数でサービスを作りやすくなったかと思います。(まともな開発経験がある訳ではないですが)
webの場合も、firebaseでhostingができるので、概ね問題ないかと思います。
cloud functionを使えばSSRも出来るようなので、表示速度が必要になるサービスでも対応できるのではないでしょうか。
まぁ動作確認のために手元で試すときは、nodeで十分です。

runtime: nodejs
env: flex
manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

現状はただのオモチャなのでyamlはこんな感じで大丈夫です。
gcloudでcliからdeployする時に、課金してないアカウントだと課金アカウントを設定しなさいというエラーが出るので、嫌な人はherokuか何かを使ってください。
遊ぶ分には、GAEの無料枠で十分いけるはずです。

GCE

GCEではElasticsearchがボタン一つで起動できます。
GCEも有料ですが、コスト感はここを見ながらやれば良いかと思います。
手元で遊ぶだけなら、ここからダウンロードして、

bin/elasticsearch

で起動すれば十分かと思います。

react native

react nativeからfirestoreに書き込むのは簡単で、addするだけです。
react nativeのコードはここに置いておきます。
これ自体は非常に雑にしか作っていないので、ネイティブアプリの実装方法を詳しく知りたい人は、
別の記事を見た方が良いかと思います。気が向いたら自分でも書きます.

doSearch = async (query) => {
    const snap = await this.ref.add(query);
    const key = snap.id;
    this.unsubscribe = this.ref_res.doc(key).onSnapshot(this.showResults);
}

たったこれだけで、Elasticsearchが使えるようになりました。
firestoreのクエリは、最低限に制限して、必要な検索はElasticsearchに任せてしまいましょう。

まとめ

まだまだ手の届かない部分もあるかと思いますが、
firebaseは十分便利だと感じています。
届かない部分に関しては、他で補えるかと思います。
今回の例で言うと、Elasticsearchを頼れば解決する問題かと思います。

また、今回の使い方とは違いますが、難解なRDBで分析をしたいと思った時でも、
一度頑張ってSQLを書いてNOSQLに全部入れて、Elasticsearchである程度スコアリングとかすれば、
扱いやすいデータに加工することも出来るのではないかと期待してます。
普通に考えて、そんな状況ならDB再設計した方が良いんですけどね

そろそろやりたいことが増えすぎて追いつかなくなってきた。

44
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?