天网恢恢,疏而不漏,全文搜索 Firestore + Elasticsearch + React Native
引言
由于Firestore的查询功能较为繁琐,我开始研究如何使用Elasticsearch进行全文搜索数据。当我使用实时数据库时,找到了一些有用的文章可供参考,但在使用Firestore时,却没有找到合适的文章。最终,我不得不费力地将实时数据库的实现方式参考并修改为适用于Firestore的方法,这一过程非常困难。我将这些经验保存下来。
使用Firebase可以使得服务端开发的意识稍微减少,但方法还没有完全确定下来,我希望有更多的人尝试并不断地分享经验。如果有任何请求,请告诉我,我会尽力去做。
目标
-
- firebase使いたいなと思ってる人
-
- firestoreのクエリに物足りなさを感じてる人
-
- Elasticsearchを使って見たい人
- 気軽にネイティブのアプリケーションを作ってみたい人
做过的事概述
flashlightを書き換える。(原型はもう留めていないかも知れない)
GAEにnodeをdeployする。
GCEにElasticsearchのインスタンスを立てる。
react nativeから検索を投げる。
解释
节点
这里已经放置了代码本身,如果不需要解释的人请克隆并使用。
如果想查看原始代码,请使用这个。
需要注意的是,原始代码是为实时数据库设计的,所以使用方式略有不同。
在从实时数据库切换到云端数据库时,我最困惑的是是否可以直接访问数据库终端。
当firebase的数据库中的数据更新时,如果要将更新传达给Elasticsearch,
在实时数据库中,只需直接访问URL即可,但在云端数据库中,需要自己进行修改。
一旦理解了,就非常简单了,只需订阅Firestore并在发生更改时进行处理。
如果使用云函数,在触发 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. 查找功能
接下来是搜索功能的实现。
首先,我们创建一个名为”search_request”和”search_response”的Firestore集合。
“search_request”集合用于接收来自原生应用的搜索条件传递到Elasticsearch,而”search_response”集合用于存放搜索结果以传递回原生应用。
如果阅读本文,并不需要读完全部内容,但看一下标有”A little bit tricky”的部分的图示会更容易理解,
这对于后续的理解将会有所帮助。
在文章中,使用了云函数和实时数据库,但在本次实现中,我们使用GAE(Google App Engine)来代替云函数,并使用Firestore代替实时数据库,但其实现方法并没有很大的差异。
在Firebase中,您可以根据对实时数据库或云Firestore中的数据所做的更改来触发函数。
this.ref_req = firebase.firestore().collection('search_request');
this.unsubscribe = this.ref_req.onSnapshot(this._showResults.bind(this));
在Firestore中,我们可以监视集合并在整个集合发生更改时触发函数。但是,我们也可以监视文档。例如,我们可以监视集合并检查已发送请求是否有相应的响应,如果有匹配的数据,则可以在React端进行处理。但是从安全性和网络通信费用以及数据库运营的角度来看,这样做似乎不太合适。因此,在返回响应时,我们只订阅文档。
可以通过订阅搜索请求,向Elasticsearch发出搜索并将结果返回给响应来实现如下。
'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();
}
将Firestore数据与Elasticsearch同步。
最後,這是關於在Firestore數據更新後向Elasticsearch發送更新的部分實現。
暫不考慮數據刪除的情況。
首先,在Firestore中創建一個名為”users”的集合。
我們希望在數據發生更改時向Elasticsearch發送更新,但在我自己操作的階段中,單獨為每個更新的文檔更新到Elasticsearch的次數有些困難,所以我嘗試使用標記進行管理。
具體來說,在react中進行更新時,我設置一個名為”ES_STATE”的字段,並將其更改為”STAY”或其他任意關鍵字。
然後,我們只訂閱”STAY”狀態的文檔,
在集合發生更改時,檢索所有”STAY”狀態的文檔並對其進行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上部署。
如果只是一个简单的后端服务,免费额度应该可以满足需求,而且也不需要考虑扩展等问题,所以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 从命令行界面部署时,如果使用的是未开启计费的帐户,会出现要求设置计费帐户的错误,所以如果不喜欢的话,可以使用 Heroku 或其他服务。
只是游玩的话,理应充分满足 GAE 免费额度的需求。
高级中学毕业文凭
在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进行一定程度的评分等处理,以便将数据转换为更易处理的形式。正常情况下,如果遇到这种情况,重新设计数据库可能会更好,但我还是抱有期望。
最近想做的事情太多了,感觉慢慢赶不上了。