Flask 教程 第十六章:全文搜索
本文轉(zhuǎn)載自:https://www.jianshu.com/p/56cfc972d372
這是Flask Mega-Tutorial系列的第十六部分,我將在其中為Microblog添加全文搜索功能。
本章的目標(biāo)是為Microblog實(shí)現(xiàn)搜索功能,以便用戶可以使用自然語(yǔ)言查找有趣的用戶動(dòng)態(tài)內(nèi)容。許多不同類型的網(wǎng)站,都可以使用Google,Bing等搜索引擎來(lái)索引所有內(nèi)容,并通過(guò)其搜索API提供搜索結(jié)果。 這這方法適用于靜態(tài)頁(yè)面較多的的大部分網(wǎng)站,比如論壇。 但在我的應(yīng)用中,基本的內(nèi)容單元是一條用戶動(dòng)態(tài),它是整個(gè)網(wǎng)頁(yè)的很小一部分。 我想要的搜索結(jié)果的類型是針對(duì)這些單獨(dú)的用戶動(dòng)態(tài)而不是整個(gè)頁(yè)面。 例如,如果我搜索單詞“dog”,我想查看任何用戶發(fā)表的包含該單詞的動(dòng)態(tài)。 很明顯,顯示所有包含“dog”(或任何其他可能的搜索字詞)的用戶動(dòng)態(tài)的頁(yè)面并不存在,大型搜索引擎也就無(wú)法索引到它。所以,我別無(wú)選擇,只能自己實(shí)現(xiàn)搜索功能。
本章的GitHub鏈接為:Browse,?Zip,?Diff.
全文搜索引擎簡(jiǎn)介
對(duì)于全文搜索的支持不像關(guān)系數(shù)據(jù)庫(kù)那樣是標(biāo)準(zhǔn)化的。 有幾種開(kāi)源的全文搜索引擎:Elasticsearch,Apache Solr,Whoosh,Xapian,Sphinx等等,如果這還不夠,常用的數(shù)據(jù)庫(kù)也可以像我上面列舉的那些專用搜索引擎一樣提供搜索服務(wù)。?SQLite,MySQL和PostgreSQL都提供了對(duì)搜索文本的支持,以及MongoDB和CouchDB等NoSQL數(shù)據(jù)庫(kù)當(dāng)然也提供這樣的功能。
如果你想知道哪些應(yīng)用程序可以在Flask應(yīng)用中運(yùn)行,那么答案就是所有! 這是Flask的強(qiáng)項(xiàng)之一,它在完成工作的同時(shí)不會(huì)自作主張。 那么到底選擇哪一個(gè)呢?
在專用搜索引擎列表中,Elasticsearch非常流行,部分原因是它在ELK棧中是用于索引日志的“E”,另兩個(gè)是Logstash和Kibana。 使用某個(gè)關(guān)系數(shù)據(jù)庫(kù)的搜索能力也是一個(gè)不錯(cuò)的選擇,但考慮到SQLAlchemy不支持這種功能,我將不得不使用原始SQL語(yǔ)句來(lái)處理搜索,否則就需要一個(gè)包, 它提供一個(gè)文本搜索的高級(jí)接口,并與SQLAlchemy共存。
基于上述分析,我將使用Elasticsearch,但我將以一種非常容易切換到另一個(gè)搜索引擎的方式來(lái)實(shí)現(xiàn)所有文本索引和搜索功能。 你可以用其他搜索引擎的替代替換我的實(shí)現(xiàn),只需在單個(gè)模塊中重寫一些函數(shù)即可。
安裝Elasticsearch
有幾種方法可以安裝Elasticsearch,包括一鍵安裝程序,帶有需要自行安裝的二進(jìn)制程序的zip包,甚至是Docker鏡像。 該文檔有一個(gè)安裝頁(yè)面,其中包含所有這些安裝選項(xiàng)的詳細(xì)信息。 如果你使用Linux,你可能會(huì)有一個(gè)可用于你的發(fā)行版的軟件包。 如果你使用的是Mac并安裝了Homebrew,那么你可以簡(jiǎn)單地運(yùn)行brew install elasticsearch。
在計(jì)算機(jī)上安裝Elasticsearch后,你可以在瀏覽器的地址欄中輸入http://localhost:9200來(lái)驗(yàn)證它是否正在運(yùn)行,預(yù)期的返回結(jié)果是JSON格式的服務(wù)基本信息。
由于我使用Python來(lái)管理Elasticsearch,因此我會(huì)使用其對(duì)應(yīng)的Python客戶端庫(kù):
(venv) $ pip install elasticsearch當(dāng)然不要忘記更新requirements.txt文件:
(venv) $ pip freeze > requirements.txtElasticsearch入門
我將在Python shell中為你展示使用Elasticsearch的基礎(chǔ)知識(shí)。 這將幫助你熟悉這項(xiàng)服務(wù),以便了解稍后將討論的實(shí)現(xiàn)部分。
要建立與Elasticsearch的連接,需要?jiǎng)?chuàng)建一個(gè)Elasticsearch類的實(shí)例,并將連接URL作為參數(shù)傳遞:
>>> from elasticsearch import Elasticsearch >>> es = Elasticsearch('http://localhost:9200')Elasticsearch中的數(shù)據(jù)需要被寫入索引中。 與關(guān)系數(shù)據(jù)庫(kù)不同,數(shù)據(jù)只是一個(gè)JSON對(duì)象。 以下示例將一個(gè)包含text字段的對(duì)象寫入名為my_index的索引:
>>> es.index(index='my_index', doc_type='my_index', id=1, body={'text': 'this is a test'})如果需要,索引可以存儲(chǔ)不同類型的文檔,在本處,可以根據(jù)不同的格式將doc_type參數(shù)設(shè)置為不同的值。 我要將所有文檔存儲(chǔ)為相同的格式,因此我將文檔類型設(shè)置為索引名稱。
對(duì)于存儲(chǔ)的每個(gè)文檔,Elasticsearch使用了一個(gè)唯一的ID來(lái)索引含有數(shù)據(jù)的JSON對(duì)象。
讓我們?cè)谶@個(gè)索引上存儲(chǔ)第二個(gè)文檔:
>>> es.index(index='my_index', doc_type='my_index', id=2, body={'text': 'a second test'})現(xiàn)在,該索引中有兩個(gè)文檔,我可以發(fā)布自由格式的搜索。 在本例中,我要搜索this test:
>>> es.search(index='my_index', doc_type='my_index', ... body={'query': {'match': {'text': 'this test'}}})來(lái)自es.search()調(diào)用的響應(yīng)是一個(gè)包含搜索結(jié)果的Python字典:
{'took': 1,'timed_out': False,'_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0},'hits': {'total': 2, 'max_score': 0.5753642, 'hits': [{'_index': 'my_index','_type': 'my_index','_id': '1','_score': 0.5753642,'_source': {'text': 'this is a test'}},{'_index': 'my_index','_type': 'my_index','_id': '2','_score': 0.25316024,'_source': {'text': 'a second test'}}]} }在結(jié)果中你可以看到搜索返回了兩個(gè)文檔,每個(gè)文檔都有一個(gè)分配的分?jǐn)?shù)。 分?jǐn)?shù)最高的文檔包含我搜索的兩個(gè)單詞,而另一個(gè)文檔只包含一個(gè)單詞。 你可以看到,即使是最好的結(jié)果的分?jǐn)?shù)也不是很高,因?yàn)檫@些單詞與文本不是完全一致的。
現(xiàn)在,如果我搜索單詞second,結(jié)果如下:
>>> es.search(index='my_index', doc_type='my_index', ... body={'query': {'match': {'text': 'second'}}}) {'took': 1,'timed_out': False,'_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0},'hits': {'total': 1,'max_score': 0.25316024,'hits': [{'_index': 'my_index','_type': 'my_index','_id': '2','_score': 0.25316024,'_source': {'text': 'a second test'}}]} }我仍然得到相當(dāng)?shù)偷姆謹(jǐn)?shù),因?yàn)槲业乃阉髋c文檔中的文本不匹配,但由于這兩個(gè)文檔中只有一個(gè)包含“second”這個(gè)詞,所以不匹配的根本不顯示。
Elasticsearch查詢對(duì)象有更多的選項(xiàng),并且很好地進(jìn)行了文檔化,其中包含諸如分頁(yè)和排序這樣的和關(guān)系數(shù)據(jù)庫(kù)一樣的功能。
隨意為此索引添加更多條目并嘗試不同的搜索。 完成試驗(yàn)后,可以使用以下命令刪除索引:
>>> es.indices.delete('my_index')Elasticsearch配置
將Elasticsearch集成到本應(yīng)用是展現(xiàn)Flask魅力的絕佳范例。 這是一個(gè)與Flask沒(méi)有任何關(guān)系的服務(wù)和Python包,然而,我將從配置開(kāi)始將它們恰如其分地集成,我先在app.config模塊中實(shí)現(xiàn)這樣的操作:
config.py:Elasticsearch 配置。
class Config(object):# ...ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')與許多其他配置條目一樣,Elasticsearch的連接URL將來(lái)自環(huán)境變量。 如果變量未定義,我將設(shè)置其為None,并將其用作禁用Elasticsearch的信號(hào)。 這主要是為了方便起見(jiàn),所以當(dāng)你運(yùn)行應(yīng)用時(shí),尤其是在運(yùn)行單元測(cè)試時(shí),不必強(qiáng)制Elasticsearch服務(wù)啟動(dòng)和運(yùn)行。 因此,為了確保服務(wù)的可用性,我需要直接在終端中定義ELASTICSEARCH_URL環(huán)境變量,或者將它添加到 .env 文件中,如下所示:
ELASTICSEARCH_URL=http://localhost:9200使用Elasticsearch面臨著非Flask插件如何使用的挑戰(zhàn)。 我不能像在上面的例子中那樣在全局范圍內(nèi)創(chuàng)建Elasticsearch實(shí)例,因?yàn)橐跏蓟?#xff0c;我需要訪問(wèn)app.config,它必須在調(diào)用create_app()函數(shù)后才可用。 所以我決定在應(yīng)用程序工廠函數(shù)中為app實(shí)例添加一個(gè)elasticsearch屬性:
app/__init__.py:Elasticsearch實(shí)例。
# ... from elasticsearch import Elasticsearch# ...def create_app(config_class=Config):app = Flask(__name__)app.config.from_object(config_class)# ...app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \if app.config['ELASTICSEARCH_URL'] else None# ...為app實(shí)例添加一個(gè)新屬性可能看起來(lái)有點(diǎn)奇怪,但是Python對(duì)象在結(jié)構(gòu)上并不嚴(yán)格,可以隨時(shí)添加新屬性。 你也可以考慮另一種方法,就是定義一個(gè)從Flask派生的子類(可以叫Microblog),然后在它的__init__()函數(shù)中定義elasticsearch屬性。
請(qǐng)留意我設(shè)計(jì)的條件表達(dá)式,如果Elasticsearch服務(wù)的URL在環(huán)境變量中未定義,則賦值None給app.elasticsearch。
全文搜索抽象化
正如我在本章的介紹中所說(shuō)的,我希望能夠輕松地從Elasticsearch切換到其他搜索引擎,并且我也不希望將此功能專門用于搜索用戶動(dòng)態(tài),我更愿意設(shè)計(jì)一個(gè)可復(fù)用的解決方案,如果需要,我可以輕松擴(kuò)展到其他模型。 出于所有這些原因,我決定將搜索功能抽象化。 我的想法是以通用條件來(lái)設(shè)計(jì)特性,所以不會(huì)假設(shè)Post模型是唯一需要編制索引的模型,也不會(huì)假設(shè)Elasticsearch是唯一選擇的搜索引擎。 但是如果我不能對(duì)任何事情做出任何假設(shè),我是不可能完成這項(xiàng)工作的!
我需要的做的第一件事,是找到一種通用的方式來(lái)指定哪個(gè)模型以及其中的某個(gè)或某些字段將被索引。 我設(shè)定任何需要索引的模型都需要定義一個(gè)__searchable__屬性,它列出了需要包含在索引中的字段。 對(duì)于Post模型來(lái)說(shuō),變化如下:
app/models.py: 為Post模型添加一個(gè)__searchable__屬性。
class Post(db.Model):__searchable__ = ['body']# ...需要說(shuō)明的是,這個(gè)模型需要有body字段才能被索引。 不過(guò),為了清楚地確保這一點(diǎn),我添加的這個(gè)__searchable__屬性只是一個(gè)變量,它沒(méi)有任何關(guān)聯(lián)的行為。 它只會(huì)幫助我以通用的方式編寫索引函數(shù)。
我將在app/search.py模塊中編寫與Elasticsearch索引交互的所有代碼。 這么做是為了將所有Elasticsearch代碼限制在這個(gè)模塊中。 應(yīng)用的其余部分將使用這個(gè)新模塊中的函數(shù)來(lái)訪問(wèn)索引,而不會(huì)直接訪問(wèn)Elasticsearch。 這很重要,因?yàn)槿绻幸惶煳也辉傧矚gElasticsearch并想切換到其他引擎,我所需要做的就是重寫這個(gè)模塊中的函數(shù),而應(yīng)用將繼續(xù)像以前一樣工作。
對(duì)于本應(yīng)用,我需要三個(gè)與文本索引相關(guān)的支持功能:我需要將條目添加到全文索引中,我需要從索引中刪除條目(假設(shè)有一天我會(huì)支持刪除用戶動(dòng)態(tài)),還有就是我需要執(zhí)行搜索查詢。 下面是app/search.py模塊,它使用我在Python控制臺(tái)中向你展示的功能實(shí)現(xiàn)Elasticsearch的這三個(gè)函數(shù):
app/search.py: Search functions.
from flask import current_appdef add_to_index(index, model):if not current_app.elasticsearch:returnpayload = {}for field in model.__searchable__:payload[field] = getattr(model, field)current_app.elasticsearch.index(index=index, doc_type=index, id=model.id,body=payload)def remove_from_index(index, model):if not current_app.elasticsearch:returncurrent_app.elasticsearch.delete(index=index, doc_type=index, id=model.id)def query_index(index, query, page, per_page):if not current_app.elasticsearch:return [], 0search = current_app.elasticsearch.search(index=index, doc_type=index,body={'query': {'multi_match': {'query': query, 'fields': ['*']}},'from': (page - 1) * per_page, 'size': per_page})ids = [int(hit['_id']) for hit in search['hits']['hits']]return ids, search['hits']['total']這些函數(shù)都是通過(guò)檢查app.elasticsearch是否為None開(kāi)始的,如果是None,則不做任何事情就返回。 當(dāng)Elasticsearch服務(wù)器未配置時(shí),應(yīng)用會(huì)在沒(méi)有搜索功能的狀態(tài)下繼續(xù)運(yùn)行,不會(huì)出現(xiàn)任何錯(cuò)誤。 這都是為了方便開(kāi)發(fā)或運(yùn)行單元測(cè)試。
這些函數(shù)接受索引名稱作為參數(shù)。 在傳遞給Elasticsearch的所有調(diào)用中,我不僅將這個(gè)名稱用作索引名稱,還將其用作文檔類型,一如我在Python控制臺(tái)示例中所做的那樣。
添加和刪除索引條目的函數(shù)將SQLAlchemy模型作為第二個(gè)參數(shù)。?add_to_index()函數(shù)使用我添加到模型中的__searchable__變量來(lái)構(gòu)建插入到索引中的文檔。 回顧一下,Elasticsearch文檔還需要一個(gè)唯一的標(biāo)識(shí)符。 為此,我使用SQLAlchemy模型的id字段,該字段正好是唯一的。 在SQLAlchemy和Elasticsearch使用相同的id值在運(yùn)行搜索時(shí)非常有用,因?yàn)樗试S我鏈接兩個(gè)數(shù)據(jù)庫(kù)中的條目。 我之前沒(méi)有提到的一點(diǎn)是,如果你嘗試添加一個(gè)帶有現(xiàn)有id的條目,那么Elasticsearch會(huì)用新的條目替換舊條目,所以add_to_index()可以用于新建和修改對(duì)象。
在remove_from_index()中的es.delete()函數(shù),我之前沒(méi)有展示過(guò)。 這個(gè)函數(shù)刪除存儲(chǔ)在給定id下的文檔。 下面是使用相同id鏈接兩個(gè)數(shù)據(jù)庫(kù)中條目的便利性的一個(gè)很好的例子。
query_index()函數(shù)使用索引名稱和文本進(jìn)行搜索,通過(guò)分頁(yè)控件,還可以像Flask-SQLAlchemy結(jié)果那樣對(duì)搜索結(jié)果進(jìn)行分頁(yè)。 你已經(jīng)從Python控制臺(tái)中看到了es.search()函數(shù)的示例用法。 我在這里發(fā)布的調(diào)用非常相似,但不是使用match查詢類型,而是使用multi_match,它可以跨多個(gè)字段進(jìn)行搜索。 通過(guò)傳遞*的字段名稱,我告訴Elasticsearch查看所有字段,所以基本上我就是搜索了整個(gè)索引。 這對(duì)于使該函數(shù)具有通用性很有用,因?yàn)椴煌哪P驮谒饕锌梢跃哂胁煌淖侄蚊Q。
es.search()查詢的body參數(shù)還包含分頁(yè)參數(shù)。?from和size參數(shù)控制整個(gè)結(jié)果集的哪些子集需要被返回。 Elasticsearch沒(méi)有像Flask-SQLAlchemy那樣提供一個(gè)很好的Pagination對(duì)象,所以我必須使用分頁(yè)數(shù)學(xué)邏輯來(lái)計(jì)算from值。
query_index()函數(shù)中的return語(yǔ)句有點(diǎn)復(fù)雜。 它返回兩個(gè)值:第一個(gè)是搜索結(jié)果的id元素列表,第二個(gè)是結(jié)果總數(shù)。 兩者都從es.search()函數(shù)返回的Python字典中獲得。 用于獲取ID列表的表達(dá)式,被稱為列表推導(dǎo)式,是Python語(yǔ)言的一個(gè)奇妙功能,它允許你將列表從一種格式轉(zhuǎn)換為另一種格式。 在本例,我使用列表推導(dǎo)式從Elasticsearch提供的更大的結(jié)果列表中提取id值。
這樣看起來(lái)是否太混亂? 也許從Python控制臺(tái)演示這些函數(shù)可以幫助你更好地理解它們。 在接下來(lái)的會(huì)話中,我手動(dòng)將數(shù)據(jù)庫(kù)中的所有用戶動(dòng)態(tài)添加到Elasticsearch索引。 在我的測(cè)試數(shù)據(jù)庫(kù)中,我有幾條用戶動(dòng)態(tài)中包含數(shù)字“one”,“two”, “three”, “four” 和“five”,因此我將其用作搜索查詢。 你可能需要調(diào)整你的查詢以匹配數(shù)據(jù)庫(kù)的內(nèi)容:
>>> from app.search import add_to_index, remove_from_index, query_index >>> for post in Post.query.all(): ... add_to_index('posts', post) >>> query_index('posts', 'one two three four five', 1, 100) ([15, 13, 12, 4, 11, 8, 14], 7) >>> query_index('posts', 'one two three four five', 1, 3) ([15, 13, 12], 7) >>> query_index('posts', 'one two three four five', 2, 3) ([4, 11, 8], 7) >>> query_index('posts', 'one two three four five', 3, 3) ([14], 7)我發(fā)出的查詢返回了七個(gè)結(jié)果。 當(dāng)我以每頁(yè)100項(xiàng)查詢第1頁(yè)時(shí),我得到了全部的七項(xiàng),但接下來(lái)的三個(gè)例子顯示了我如何以與Flask-SQLAlchemy類似的方式對(duì)結(jié)果進(jìn)行分頁(yè),當(dāng)然,結(jié)果是ID列表而不是SQLAlchemy對(duì)象。
如果你想保持?jǐn)?shù)據(jù)的清潔,可以在做實(shí)驗(yàn)之后刪除posts索引:
>>> app.elasticsearch.indices.delete('posts')集成SQLAlchemy到搜索
我在前面的章節(jié)中給出的解決方案是可行的,但它仍然存在一些問(wèn)題。 最明顯的問(wèn)題是結(jié)果是以數(shù)字ID列表的形式出現(xiàn)的。 這非常不方便,我需要SQLAlchemy模型,以便我可以將它們傳遞給模板進(jìn)行渲染,并且我需要用數(shù)據(jù)庫(kù)中相應(yīng)模型替換數(shù)字列表的方法。 第二個(gè)問(wèn)題是,這個(gè)解決方案需要應(yīng)用在添加或刪除用戶動(dòng)態(tài)時(shí)明確地發(fā)出對(duì)應(yīng)的索引調(diào)用,這并非不可行,但并不理想,因?yàn)樵赟QLAlchemy側(cè)進(jìn)行更改時(shí)錯(cuò)過(guò)索引調(diào)用的情況是不容易被檢測(cè)到的,每當(dāng)發(fā)生這種情況時(shí),兩個(gè)數(shù)據(jù)庫(kù)就會(huì)越來(lái)越不同步,并且你可能在一段時(shí)間內(nèi)都不會(huì)注意到。 更好的解決方案是在SQLAlchemy數(shù)據(jù)庫(kù)進(jìn)行更改時(shí)自動(dòng)觸發(fā)這些調(diào)用。
用對(duì)象替換ID的問(wèn)題可以通過(guò)創(chuàng)建一個(gè)從數(shù)據(jù)庫(kù)讀取這些對(duì)象的SQLAlchemy查詢來(lái)解決。 這在實(shí)踐中聽(tīng)起來(lái)很容易,但是使用單個(gè)查詢來(lái)高效地實(shí)現(xiàn)它實(shí)際上有點(diǎn)棘手。
對(duì)于自動(dòng)觸發(fā)索引更改的問(wèn)題,我決定用SQLAlchemy?事件驅(qū)動(dòng)Elasticsearch索引的更新。 SQLAlchemy提供了大量的事件,可以通知應(yīng)用程序。 例如,每次提交會(huì)話時(shí),我都可以定義一個(gè)由SQLAlchemy調(diào)用的函數(shù),并且在該函數(shù)中,我可以將SQLAlchemy會(huì)話中的更新應(yīng)用于Elasticsearch索引。
為了實(shí)現(xiàn)這兩個(gè)問(wèn)題的解決方案,我將編寫mixin類。 記得mixin類嗎? 在第五章中,我將Flask-Login中的UserMixin類添加到了User模型,為它提供Flask-Login所需的一些功能。 對(duì)于搜索支持,我將定義我自己的SearchableMixin類,當(dāng)它被添加到模型時(shí),可以自動(dòng)管理與SQLAlchemy模型關(guān)聯(lián)的全文索引。 mixin類將充當(dāng)SQLAlchemy和Elasticsearch世界之間的“粘合”層,為我上面提到的兩個(gè)問(wèn)題提供解決方案。
讓我先告訴你實(shí)現(xiàn),然后再來(lái)回顧一些有趣的細(xì)節(jié)。 請(qǐng)注意,這使用了多種先進(jìn)技術(shù),因此你需要仔細(xì)研究此代碼以充分理解它。
app/models.py:SearchableMixin類。
from app.search import add_to_index, remove_from_index, query_indexclass SearchableMixin(object):@classmethoddef search(cls, expression, page, per_page):ids, total = query_index(cls.__tablename__, expression, page, per_page)if total == 0:return cls.query.filter_by(id=0), 0when = []for i in range(len(ids)):when.append((ids[i], i))return cls.query.filter(cls.id.in_(ids)).order_by(db.case(when, value=cls.id)), total@classmethoddef before_commit(cls, session):session._changes = {'add': [obj for obj in session.new if isinstance(obj, cls)],'update': [obj for obj in session.dirty if isinstance(obj, cls)],'delete': [obj for obj in session.deleted if isinstance(obj, cls)]}@classmethoddef after_commit(cls, session):for obj in session._changes['add']:add_to_index(cls.__tablename__, obj)for obj in session._changes['update']:add_to_index(cls.__tablename__, obj)for obj in session._changes['delete']:remove_from_index(cls.__tablename__, obj)session._changes = None@classmethoddef reindex(cls):for obj in cls.query:add_to_index(cls.__tablename__, obj)這個(gè)mixin類有四個(gè)函數(shù),都是類方法。復(fù)習(xí)一下,類方法是與類相關(guān)聯(lián)的特殊方法,而不是實(shí)例的。 請(qǐng)注意,我將常規(guī)實(shí)例方法中使用的self參數(shù)重命名為cls,以明確此方法接收的是類而不是實(shí)例作為其第一個(gè)參數(shù)。 例如,一旦連接到Post模型,上面的search()方法將被調(diào)用為Post.search(),而不必將其實(shí)例化。
search()類方法封裝來(lái)自app/search.py??的query_index()函數(shù)以將對(duì)象ID列表替換成實(shí)例對(duì)象。你可以看到這個(gè)函數(shù)做的第一件事就是調(diào)用query_index(),并傳遞cls .__tablename__作為索引名稱。這將是一個(gè)約定,所有索引都將用Flask-SQLAlchemy模型關(guān)聯(lián)的表名。該函數(shù)返回結(jié)果ID列表和結(jié)果總數(shù)。通過(guò)它們的ID檢索對(duì)象列表的SQLAlchemy查詢基于SQL語(yǔ)言的CASE語(yǔ)句,該語(yǔ)句需要用于確保數(shù)據(jù)庫(kù)中的結(jié)果與給定ID的順序相同。這很重要,因?yàn)镋lasticsearch查詢返回的結(jié)果不是有序的。如果你想了解更多關(guān)于這個(gè)查詢的工作方式,你可以參考這個(gè)StackOverflow問(wèn)題的接受答案。search()函數(shù)返回替換ID列表的查詢結(jié)果集,以及搜索結(jié)果的總數(shù)。
before_commit()和after_commit()方法分別對(duì)應(yīng)來(lái)自SQLAlchemy的兩個(gè)事件,這兩個(gè)事件分別在提交發(fā)生之前和之后觸發(fā)。 前置處理功能很有用,因?yàn)闀?huì)話還沒(méi)有提交,所以我可以查看并找出將要添加,修改和刪除的對(duì)象,如session.new,session.dirty和session.deleted。 這些對(duì)象在會(huì)話提交后不再可用,所以我需要在提交之前保存它們。 我使用session._changes字典將這些對(duì)象寫入會(huì)話提交后仍然存在的地方,因?yàn)橐坏?huì)話被提交,我將使用它們來(lái)更新Elasticsearch索引。
當(dāng)調(diào)用after_commit()處理程序時(shí),會(huì)話已成功提交,因此這是在Elasticsearch端進(jìn)行更新的適當(dāng)時(shí)間。 session對(duì)象具有before_commit()中添加的_changes變量,所以現(xiàn)在我可以迭代需要被添加,修改和刪除的對(duì)象,并對(duì)app/search.py中的索引函數(shù)進(jìn)行相應(yīng)的調(diào)用。
reindex()類方法是一個(gè)簡(jiǎn)單的幫助方法,你可以使用它來(lái)刷新所有數(shù)據(jù)的索引。 你看到我在上面做的將所有用戶動(dòng)態(tài)初始加載到測(cè)試索引中,這個(gè)操作與Python shell會(huì)話中的類似。 有了這個(gè)方法,我可以調(diào)用Post.reindex()將數(shù)據(jù)庫(kù)中的所有用戶動(dòng)態(tài)添加到搜索索引中。
為了將SearchableMixin類整合到Post模型中,我必須將它作為Post的基類,并且還需要監(jiān)聽(tīng)提交之前和之后的事件:
app/models.py:添加SearchableMixin類到Post模型。
class Post(SearchableMixin, db.Model):# ...db.event.listen(db.session, 'before_commit', Post.before_commit) db.event.listen(db.session, 'after_commit', Post.after_commit)請(qǐng)注意,db.event.listen()調(diào)用不在類內(nèi)部,而是在其后面。 這兩行代碼設(shè)置了每次提交之前和之后調(diào)用的事件處理程序。 現(xiàn)在Post模型會(huì)自動(dòng)為用戶動(dòng)態(tài)維護(hù)一個(gè)全文搜索索引。 我可以使用reindex()方法來(lái)初始化當(dāng)前在數(shù)據(jù)庫(kù)中的所有用戶動(dòng)態(tài)的索引:
>>> Post.reindex()我可以通過(guò)運(yùn)行Post.search()來(lái)搜索使用SQLAlchemy模型的用戶動(dòng)態(tài)。 在下面的例子中,我要求查詢第一頁(yè)的五個(gè)元素:
>>> query, total = Post.search('one two three four five', 1, 5) >>> total 7 >>> query.all() [<Post five>, <Post two>, <Post one>, <Post one more>, <Post one>]搜索表單
的確有些激進(jìn)。 我上面做的保持通用性的工作涉及到幾個(gè)高級(jí)主題,因此可能需要一些時(shí)間才能完全理解。 現(xiàn)在我有一套完整的系統(tǒng)來(lái)處理用戶動(dòng)態(tài)的自然語(yǔ)言搜索。 所以現(xiàn)在需要做的是將所有這些功能與應(yīng)用集成在一起。
基于網(wǎng)絡(luò)搜索的一種相當(dāng)標(biāo)準(zhǔn)的方法是在URL的查詢字符串中將搜索詞作為q參數(shù)的值。 例如,如果你想在Google上搜索Python,并且想要節(jié)約少許時(shí)間,則只需在瀏覽器的地址欄中輸入以下URL即可直接查看結(jié)果:
https://www.google.com/search?q=python允許將搜索完全封裝在URL中是很好的,因?yàn)檫@方便了與其他人共享,只要點(diǎn)擊鏈接就可以訪問(wèn)搜索結(jié)果。
請(qǐng)?jiān)试S我向你介紹一種區(qū)別于以前的Web表單的處理方式。 我曾經(jīng)使用POST請(qǐng)求來(lái)提交表單數(shù)據(jù),但是為了實(shí)現(xiàn)上述搜索,表單提交必須以GET請(qǐng)求發(fā)送,這是一種請(qǐng)求方法,當(dāng)你在瀏覽器中輸入網(wǎng)址或點(diǎn)擊鏈接時(shí),就是GET請(qǐng)求。 另一個(gè)有趣的區(qū)別是搜索表單將存在于導(dǎo)航欄中,因此它將會(huì)出現(xiàn)應(yīng)用的所有頁(yè)面中。
這里是搜索表單類,只有q文本字段:
app/main/forms.py:搜索表單。
from flask import requestclass SearchForm(FlaskForm):q = StringField(_l('Search'), validators=[DataRequired()])def __init__(self, *args, **kwargs):if 'formdata' not in kwargs:kwargs['formdata'] = request.argsif 'csrf_enabled' not in kwargs:kwargs['csrf_enabled'] = Falsesuper(SearchForm, self).__init__(*args, **kwargs)q字段不需要任何解釋,因?yàn)樗c我以前使用的其他文本字段相似。在這個(gè)表單中,我不需要提交按鈕。對(duì)于具有文本字段的表單,當(dāng)焦點(diǎn)位于該字段上時(shí),你按下Enter鍵,瀏覽器將提交表單,因此不需要按鈕。我還添加了一個(gè)__init__構(gòu)造函數(shù),它提供了formdata和csrf_enabled參數(shù)的值(如果調(diào)用者沒(méi)有提供它們的話)。?formdata參數(shù)決定Flask-WTF從哪里獲取表單提交。缺省情況是使用request.form,這是Flask放置通過(guò)POST請(qǐng)求??提交的表單值的地方。通過(guò)GET請(qǐng)求提交的表單在查詢字符串中傳遞字段值,所以我需要將Flask-WTF指向request.args,這是Flask寫查詢字符串參數(shù)的地方。你是否還記得的,表單默認(rèn)添加了CSRF保護(hù),包含一個(gè)CSRF標(biāo)記,該標(biāo)記通過(guò)模板中的form.hidden_??tag()構(gòu)造添加到表單中。為了使搜索表單運(yùn)作,CSRF需要被禁用,所以我將csrf_enabled設(shè)置為False,以便Flask-WTF知道它需要忽略此表單的CSRF驗(yàn)證。
由于我需要在所有頁(yè)面中都顯示此表單,因此無(wú)論用戶在查看哪個(gè)頁(yè)面,我都需要?jiǎng)?chuàng)建一個(gè)SearchForm類的實(shí)例。 唯一的要求是用戶登錄,因?yàn)閷?duì)于匿名用戶,我目前不會(huì)顯示任何內(nèi)容。 與其在每個(gè)路由中創(chuàng)建表單對(duì)象,然后將表單傳遞給所有模板,我將向你展示一個(gè)非常有用的技巧,當(dāng)你需要在整個(gè)應(yīng)用中實(shí)現(xiàn)一個(gè)功能時(shí),可以消除重復(fù)代碼。 回到第六章,我已經(jīng)使用了before_request處理程序, 來(lái)記錄每個(gè)用戶上次訪問(wèn)的時(shí)間。 我要做的是在同樣的功能中創(chuàng)建我的搜索表單,但有一點(diǎn)區(qū)別:
app/main/routes.py:在請(qǐng)求處理前的處理器中初始化搜索表單。
from flask import g from app.main.forms import SearchForm@bp.before_app_request def before_request():if current_user.is_authenticated:current_user.last_seen = datetime.utcnow()db.session.commit()g.search_form = SearchForm()g.locale = str(get_locale())在這里,當(dāng)用戶已認(rèn)證時(shí),我會(huì)創(chuàng)建一個(gè)搜索表單類的實(shí)例。當(dāng)然,我需要這個(gè)表單對(duì)象一直存在,直到它可以在請(qǐng)求結(jié)束時(shí)渲染,所以我需要將它存儲(chǔ)在某個(gè)地方。那個(gè)地方就是Flask提供的g容器。這個(gè)g變量是應(yīng)用可以存儲(chǔ)需要在整個(gè)請(qǐng)求期間持續(xù)存在的數(shù)據(jù)的地方。在這里,我將表單存儲(chǔ)在g.search_form中,所以當(dāng)請(qǐng)求前置處理程序結(jié)束并且Flask調(diào)用處理請(qǐng)求的URL的視圖函數(shù)時(shí),g對(duì)象將會(huì)是相同的,并且表單仍然存在。請(qǐng)注意,這個(gè)g變量對(duì)每個(gè)請(qǐng)求和每個(gè)客戶端都是特定的,因此即使你的Web服務(wù)器一次為不同的客戶端處理多個(gè)請(qǐng)求,仍然可以依靠g來(lái)專用存儲(chǔ)各個(gè)請(qǐng)求的對(duì)應(yīng)變量。
下一步是將表單渲染成頁(yè)面。 我在上面說(shuō)過(guò),我想在所有頁(yè)面中展示這個(gè)表單,所以更有意義的是將其作為導(dǎo)航欄的一部分進(jìn)行渲染。 事實(shí)上,這很簡(jiǎn)單,因?yàn)槟0逡部梢钥吹酱鎯?chǔ)在g變量中的數(shù)據(jù),所以我不需要在所有render_template()調(diào)用中將表單作為顯式模板參數(shù)添加進(jìn)去。以下是我如何在基礎(chǔ)模板中渲染表單的代碼:
app/templates/base.html:在導(dǎo)航欄中渲染搜索表單。
...<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1"><ul class="nav navbar-nav">... home and explore links ...</ul>{% if g.search_form %}<form class="navbar-form navbar-left" method="get"action="{{ url_for('main.search') }}"><div class="form-group">{{ g.search_form.q(size=20, class='form-control',placeholder=g.search_form.q.label.text) }}</div></form>{% endif %}...只有在定義了g.search_form時(shí)才會(huì)渲染表單。 此檢查是必要的,因?yàn)槟承╉?yè)面(如錯(cuò)誤頁(yè)面)可能沒(méi)有定義它。 這個(gè)表單與我之前做過(guò)的略有不同。 我將method屬性設(shè)置為get,因?yàn)槲蚁M韱螖?shù)據(jù)作為查詢字符串,通過(guò)GET請(qǐng)求提交。 另外,我創(chuàng)建的其他表單action屬性為空,因?yàn)樗鼈儽惶峤坏戒秩颈韱蔚耐豁?yè)面。 而這個(gè)表單很特殊,因?yàn)樗霈F(xiàn)在所有頁(yè)面中,所以我需要明確告訴它需要提交的地方,這是專門用于處理搜索的新路由。
搜索視圖函數(shù)
完成搜索功能的最后一項(xiàng)功能是接收搜索表單的視圖函數(shù)。 該視圖函數(shù)將被附加到/search路由,以便你可以發(fā)送類似http://localhost:5000/search?q=search-words的搜索請(qǐng)求,就像Google一樣。
app/main/routes.py:搜索視圖函數(shù)。
@bp.route('/search') @login_required def search():if not g.search_form.validate():return redirect(url_for('main.explore'))page = request.args.get('page', 1, type=int)posts, total = Post.search(g.search_form.q.data, page,current_app.config['POSTS_PER_PAGE'])next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \if total > page * current_app.config['POSTS_PER_PAGE'] else Noneprev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \if page > 1 else Nonereturn render_template('search.html', title=_('Search'), posts=posts,next_url=next_url, prev_url=prev_url)你已經(jīng)看到,在其他表單中,我使用form.validate_on_submit()方法來(lái)檢查表單提交是否有效。 不幸的是,該方法只適用于通過(guò)POST請(qǐng)求提交的表單,所以對(duì)于這個(gè)表單,我需要使用form.validate(),它只驗(yàn)證字段值,而不檢查數(shù)據(jù)是如何提交的。 如果驗(yàn)證失敗,這是因?yàn)橛脩籼峤涣艘粋€(gè)空的搜索表單,所以在這種情況下,我只能重定向到了顯示所有用戶動(dòng)態(tài)的發(fā)現(xiàn)頁(yè)面。
SearchableMixin類中的Post.search()方法用于獲取搜索結(jié)果列表。 分頁(yè)的處理方式與主頁(yè)和發(fā)現(xiàn)頁(yè)面非常類似,但如果沒(méi)有Flask-SQLAlchemy的“分頁(yè)”對(duì)象的幫助,生成下一個(gè)和前一個(gè)鏈接會(huì)有點(diǎn)棘手。 這是從Post.search()返回的結(jié)果總數(shù)的用途所在。
一旦計(jì)算出搜索結(jié)果和分頁(yè)鏈接的頁(yè)面,剩下的就是渲染一個(gè)包含所有這些數(shù)據(jù)的模板。 我已經(jīng)想出了一種重用index.html模板來(lái)顯示搜索結(jié)果的方法,但考慮到有一些差異,我決定創(chuàng)建一個(gè)專用于顯示搜索結(jié)果的search.html專屬模板, 以_post.html子模板的優(yōu)勢(shì)來(lái)渲染搜索結(jié)果:
app/templates/search.html:搜索結(jié)果模板。
{% extends "base.html" %}{% block app_content %}<h1>{{ _('Search Results') }}</h1>{% for post in posts %}{% include '_post.html' %}{% endfor %}<nav aria-label="..."><ul class="pager"><li class="previous{% if not prev_url %} disabled{% endif %}"><a href="{{ prev_url or '#' }}"><span aria-hidden="true">←</span>{{ _('Previous results') }}</a></li><li class="next{% if not next_url %} disabled{% endif %}"><a href="{{ next_url or '#' }}">{{ _('Next results') }}<span aria-hidden="true">→</span></a></li></ul></nav> {% endblock %}如果前一個(gè)和下一個(gè)鏈接的渲染邏輯有點(diǎn)混亂,可能查看分頁(yè)組件的Bootstrap文檔會(huì)有所幫助。
感想如何? 本章的內(nèi)容有些激進(jìn),因?yàn)槔锩娼榻B了一些相當(dāng)先進(jìn)的技術(shù)。 本章中的一些概念可能需要你花一些時(shí)間才能有所領(lǐng)悟。本章最重要的一點(diǎn)是,如果你想使用與Elasticsearch不同的搜索引擎,只需要重寫app/search.py即可。 通過(guò)這項(xiàng)工作的另一個(gè)重要好處是,如果我需要為另外的數(shù)據(jù)庫(kù)模型添加搜索支持,我可以簡(jiǎn)單地通過(guò)向它添加SearchableMixin類,為_(kāi)_searchable__屬性填寫要索引的字段列表和SQLAlchemy事件處理程序的監(jiān)聽(tīng)即可。 我認(rèn)為這些努力是值得的,因?yàn)閺默F(xiàn)在起,處理全文索引將會(huì)變得十分容易。
總結(jié)
以上是生活随笔為你收集整理的Flask 教程 第十六章:全文搜索的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: C++ 常用函数总结
- 下一篇: java8 - 新的时间日期API示例