【百度云搜索,搜各種資料:http://bdy.lqkweb.com】
【搜網(wǎng)盤,搜各種資料:http://www.swpan.cn】
本文翻譯自The Flask Mega-Tutorial Part XVI: Full-Text Search
這是Flask Mega-Tutorial系列的第十六部分,我將在其中為Microblog添加全文搜索功能。
本章的目標(biāo)是為Microblog實(shí)現(xiàn)搜索功能,以便用戶可以使用自然語言查找有趣的用戶動(dòng)態(tài)內(nèi)容。許多不同類型的網(wǎng)站,都可以使用Google,Bing等搜索引擎來索引所有內(nèi)容,并通過其搜索API提供搜索結(jié)果。 這這方法適用于靜態(tài)頁面較多的的大部分網(wǎng)站,比如論壇。 但在我的應(yīng)用中,基本的內(nèi)容單元是一條用戶動(dòng)態(tài),它是整個(gè)網(wǎng)頁的很小一部分。 我想要的搜索結(jié)果的類型是針對(duì)這些單獨(dú)的用戶動(dòng)態(tài)而不是整個(gè)頁面。 例如,如果我搜索單詞“dog”,我想查看任何用戶發(fā)表的包含該單詞的動(dòng)態(tài)。 很明顯,顯示所有包含“dog”(或任何其他可能的搜索字詞)的用戶動(dòng)態(tài)的頁面并不存在,大型搜索引擎也就無法索引到它。所以,我別無選擇,只能自己實(shí)現(xiàn)搜索功能。
本章的GitHub鏈接為:Browse, Zip, Diff.
全文搜索引擎簡介
對(duì)于全文搜索的支持不像關(guān)系數(shù)據(jù)庫那樣是標(biāo)準(zhǔn)化的。 有幾種開源的全文搜索引擎:Elasticsearch,Apache Solr,Whoosh,Xapian,Sphinx等等,如果這還不夠,常用的數(shù)據(jù)庫也可以像我上面列舉的那些專用搜索引擎一樣提供搜索服務(wù)。 SQLite,MySQL和PostgreSQL都提供了對(duì)搜索文本的支持,以及MongoDB和CouchDB等NoSQL數(shù)據(jù)庫當(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ù)庫的搜索能力也是一個(gè)不錯(cuò)的選擇,但考慮到SQLAlchemy不支持這種功能,我將不得不使用原始SQL語句來處理搜索,否則就需要一個(gè)包, 它提供一個(gè)文本搜索的高級(jí)接口,并與SQLAlchemy共存。
基于上述分析,我將使用Elasticsearch,但我將以一種非常容易切換到另一個(gè)搜索引擎的方式來實(shí)現(xiàn)所有文本索引和搜索功能。 你可以用其他搜索引擎的替代替換我的實(shí)現(xiàn),只需在單個(gè)模塊中重寫一些函數(shù)即可。
安裝Elasticsearch
有幾種方法可以安裝Elasticsearch,包括一鍵安裝程序,帶有需要自行安裝的二進(jìn)制程序的zip包,甚至是Docker鏡像。 該文檔有一個(gè)安裝頁面,其中包含所有這些安裝選項(xiàng)的詳細(xì)信息。 如果你使用Linux,你可能會(huì)有一個(gè)可用于你的發(fā)行版的軟件包。 如果你使用的是Mac并安裝了Homebrew,那么你可以簡單地運(yùn)行brew install elasticsearch。
在計(jì)算機(jī)上安裝Elasticsearch后,你可以在瀏覽器的地址欄中輸入http://localhost:9200來驗(yàn)證它是否正在運(yùn)行,預(yù)期的返回結(jié)果是JSON格式的服務(wù)基本信息。
由于我使用Python來管理Elasticsearch,因此我會(huì)使用其對(duì)應(yīng)的Python客戶端庫:
(venv) $ pip install elasticsearch
當(dāng)然不要忘記更新requirements.txt文件:
(venv) $ pip freeze > requirements.txt
Elasticsearch入門
我將在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ù)庫不同,數(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來索引含有數(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'}}})
來自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)行了文檔化,其中包含諸如分頁和排序這樣的和關(guān)系數(shù)據(jù)庫一樣的功能。
隨意為此索引添加更多條目并嘗試不同的搜索。 完成試驗(yàn)后,可以使用以下命令刪除索引:
>>> es.indices.delete('my_index')
Elasticsearch配置
將Elasticsearch集成到本應(yīng)用是展現(xiàn)Flask魅力的絕佳范例。 這是一個(gè)與Flask沒有任何關(guān)系的服務(wù)和Python包,然而,我將從配置開始將它們恰如其分地集成,我先在app.config模塊中實(shí)現(xiàn)這樣的操作:
config.py:Elasticsearch 配置。
class Config(object):
# ...
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
與許多其他配置條目一樣,Elasticsearch的連接URL將來自環(huán)境變量。 如果變量未定義,我將設(shè)置其為None,并將其用作禁用Elasticsearch的信號(hào)。 這主要是為了方便起見,所以當(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)橐跏蓟?,我需要訪問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è)新屬性可能看起來有點(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。
全文搜索抽象化
正如我在本章的介紹中所說的,我希望能夠輕松地從Elasticsearch切換到其他搜索引擎,并且我也不希望將此功能專門用于搜索用戶動(dòng)態(tài),我更愿意設(shè)計(jì)一個(gè)可復(fù)用的解決方案,如果需要,我可以輕松擴(kuò)展到其他模型。 出于所有這些原因,我決定將搜索功能抽象化。 我的想法是以通用條件來設(shè)計(jì)特性,所以不會(huì)假設(shè)Post模型是唯一需要編制索引的模型,也不會(huì)假設(shè)Elasticsearch是唯一選擇的搜索引擎。 但是如果我不能對(duì)任何事情做出任何假設(shè),我是不可能完成這項(xiàng)工作的!
我需要的做的第一件事,是找到一種通用的方式來指定哪個(gè)模型以及其中的某個(gè)或某些字段將被索引。 我設(shè)定任何需要索引的模型都需要定義一個(gè)__searchable__屬性,它列出了需要包含在索引中的字段。 對(duì)于Post模型來說,變化如下:
app/models.py: 為Post模型添加一個(gè)searchable屬性。
class Post(db.Model):
__searchable__ = ['body']
# ...
需要說明的是,這個(gè)模型需要有body字段才能被索引。 不過,為了清楚地確保這一點(diǎn),我添加的這個(gè)__searchable__屬性只是一個(gè)變量,它沒有任何關(guān)聯(lián)的行為。 它只會(huì)幫助我以通用的方式編寫索引函數(shù)。
我將在app/search.py模塊中編寫與Elasticsearch索引交互的所有代碼。 這么做是為了將所有Elasticsearch代碼限制在這個(gè)模塊中。 應(yīng)用的其余部分將使用這個(gè)新模塊中的函數(shù)來訪問索引,而不會(huì)直接訪問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_app
def add_to_index(index, model):
if not current_app.elasticsearch:
return
payload = {}
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:
return
current_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 [], 0
search = 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ù)都是通過檢查app.elasticsearch是否為None開始的,如果是None,則不做任何事情就返回。 當(dāng)Elasticsearch服務(wù)器未配置時(shí),應(yīng)用會(huì)在沒有搜索功能的狀態(tài)下繼續(xù)運(yùn)行,不會(huì)出現(xiàn)任何錯(cuò)誤。 這都是為了方便開發(fā)或運(yùn)行單元測(cè)試。
這些函數(shù)接受索引名稱作為參數(shù)。 在傳遞給Elasticsearch的所有調(diào)用中,我不僅將這個(gè)名稱用作索引名稱,還將其用作文檔類型,一如我在Python控制臺(tái)示例中所做的那樣。
添加和刪除索引條目的函數(shù)將SQLAlchemy模型作為第二個(gè)參數(shù)。 add_to_index()函數(shù)使用我添加到模型中的__searchable__變量來構(gòu)建插入到索引中的文檔。 回顧一下,Elasticsearch文檔還需要一個(gè)唯一的標(biāo)識(shí)符。 為此,我使用SQLAlchemy模型的id字段,該字段正好是唯一的。 在SQLAlchemy和Elasticsearch使用相同的id值在運(yùn)行搜索時(shí)非常有用,因?yàn)樗试S我鏈接兩個(gè)數(shù)據(jù)庫中的條目。 我之前沒有提到的一點(diǎn)是,如果你嘗試添加一個(gè)帶有現(xiàn)有id的條目,那么Elasticsearch會(huì)用新的條目替換舊條目,所以add_to_index()可以用于新建和修改對(duì)象。
在remove_from_index()中的es.delete()函數(shù),我之前沒有展示過。 這個(gè)函數(shù)刪除存儲(chǔ)在給定id下的文檔。 下面是使用相同id鏈接兩個(gè)數(shù)據(jù)庫中條目的便利性的一個(gè)很好的例子。
query_index()函數(shù)使用索引名稱和文本進(jìn)行搜索,通過分頁控件,還可以像Flask-SQLAlchemy結(jié)果那樣對(duì)搜索結(jié)果進(jìn)行分頁。 你已經(jīng)從Python控制臺(tái)中看到了es.search()函數(shù)的示例用法。 我在這里發(fā)布的調(diào)用非常相似,但不是使用match查詢類型,而是使用multi_match,它可以跨多個(gè)字段進(jìn)行搜索。 通過傳遞*的字段名稱,我告訴Elasticsearch查看所有字段,所以基本上我就是搜索了整個(gè)索引。 這對(duì)于使該函數(shù)具有通用性很有用,因?yàn)椴煌哪P驮谒饕锌梢跃哂胁煌淖侄蚊Q。
es.search()查詢的body參數(shù)還包含分頁參數(shù)。 from和size參數(shù)控制整個(gè)結(jié)果集的哪些子集需要被返回。 Elasticsearch沒有像Flask-SQLAlchemy那樣提供一個(gè)很好的Pagination對(duì)象,所以我必須使用分頁數(shù)學(xué)邏輯來計(jì)算from值。
query_index()函數(shù)中的return語句有點(diǎn)復(fù)雜。 它返回兩個(gè)值:第一個(gè)是搜索結(jié)果的id元素列表,第二個(gè)是結(jié)果總數(shù)。 兩者都從es.search()函數(shù)返回的Python字典中獲得。 用于獲取ID列表的表達(dá)式,被稱為列表推導(dǎo)式,是Python語言的一個(gè)奇妙功能,它允許你將列表從一種格式轉(zhuǎn)換為另一種格式。 在本例,我使用列表推導(dǎo)式從Elasticsearch提供的更大的結(jié)果列表中提取id值。
這樣看起來是否太混亂? 也許從Python控制臺(tái)演示這些函數(shù)可以幫助你更好地理解它們。 在接下來的會(huì)話中,我手動(dòng)將數(shù)據(jù)庫中的所有用戶動(dòng)態(tài)添加到Elasticsearch索引。 在我的測(cè)試數(shù)據(jù)庫中,我有幾條用戶動(dòng)態(tài)中包含數(shù)字“one”,“two”, “three”, “four” 和“five”,因此我將其用作搜索查詢。 你可能需要調(diào)整你的查詢以匹配數(shù)據(jù)庫的內(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)我以每頁100項(xiàng)查詢第1頁時(shí),我得到了全部的七項(xiàng),但接下來的三個(gè)例子顯示了我如何以與Flask-SQLAlchemy類似的方式對(duì)結(jié)果進(jìn)行分頁,當(dāng)然,結(jié)果是ID列表而不是SQLAlchemy對(duì)象。
如果你想保持?jǐn)?shù)據(jù)的清潔,可以在做實(shí)驗(yàn)之后刪除posts索引:
>>> app.elasticsearch.indices.delete('posts')
集成SQLAlchemy到搜索
我在前面的章節(jié)中給出的解決方案是可行的,但它仍然存在一些問題。 最明顯的問題是結(jié)果是以數(shù)字ID列表的形式出現(xiàn)的。 這非常不方便,我需要SQLAlchemy模型,以便我可以將它們傳遞給模板進(jìn)行渲染,并且我需要用數(shù)據(jù)庫中相應(yīng)模型替換數(shù)字列表的方法。 第二個(gè)問題是,這個(gè)解決方案需要應(yīng)用在添加或刪除用戶動(dòng)態(tài)時(shí)明確地發(fā)出對(duì)應(yīng)的索引調(diào)用,這并非不可行,但并不理想,因?yàn)樵赟QLAlchemy側(cè)進(jìn)行更改時(shí)錯(cuò)過索引調(diào)用的情況是不容易被檢測(cè)到的,每當(dāng)發(fā)生這種情況時(shí),兩個(gè)數(shù)據(jù)庫就會(huì)越來越不同步,并且你可能在一段時(shí)間內(nèi)都不會(huì)注意到。 更好的解決方案是在SQLAlchemy數(shù)據(jù)庫進(jìn)行更改時(shí)自動(dòng)觸發(fā)這些調(diào)用。
用對(duì)象替換ID的問題可以通過創(chuàng)建一個(gè)從數(shù)據(jù)庫讀取這些對(duì)象的SQLAlchemy查詢來解決。 這在實(shí)踐中聽起來很容易,但是使用單個(gè)查詢來高效地實(shí)現(xiàn)它實(shí)際上有點(diǎn)棘手。
對(duì)于自動(dòng)觸發(fā)索引更改的問題,我決定用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è)問題的解決方案,我將編寫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è)問題提供解決方案。
讓我先告訴你實(shí)現(xiàn),然后再來回顧一些有趣的細(xì)節(jié)。 請(qǐng)注意,這使用了多種先進(jìn)技術(shù),因此你需要仔細(xì)研究此代碼以充分理解它。
app/models.py:SearchableMixin類。
from app.search import add_to_index, remove_from_index, query_index
class SearchableMixin(object):
@classmethod
def 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), 0
when = []
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
@classmethod
def 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)]
}
@classmethod
def 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
@classmethod
def 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()類方法封裝來自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ù)。通過它們的ID檢索對(duì)象列表的SQLAlchemy查詢基于SQL語言的CASE語句,該語句需要用于確保數(shù)據(jù)庫中的結(jié)果與給定ID的順序相同。這很重要,因?yàn)镋lasticsearch查詢返回的結(jié)果不是有序的。如果你想了解更多關(guān)于這個(gè)查詢的工作方式,你可以參考這個(gè)StackOverflow問題的接受答案。search()函數(shù)返回替換ID列表的查詢結(jié)果集,以及搜索結(jié)果的總數(shù)。
before_commit()和after_commit()方法分別對(duì)應(yīng)來自SQLAlchemy的兩個(gè)事件,這兩個(gè)事件分別在提交發(fā)生之前和之后觸發(fā)。 前置處理功能很有用,因?yàn)闀?huì)話還沒有提交,所以我可以查看并找出將要添加,修改和刪除的對(duì)象,如session.new,session.dirty和session.deleted。 這些對(duì)象在會(huì)話提交后不再可用,所以我需要在提交之前保存它們。 我使用session._changes字典將這些對(duì)象寫入會(huì)話提交后仍然存在的地方,因?yàn)橐坏?huì)話被提交,我將使用它們來更新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è)簡單的幫助方法,你可以使用它來刷新所有數(shù)據(jù)的索引。 你看到我在上面做的將所有用戶動(dòng)態(tài)初始加載到測(cè)試索引中,這個(gè)操作與Python shell會(huì)話中的類似。 有了這個(gè)方法,我可以調(diào)用Post.reindex()將數(shù)據(jù)庫中的所有用戶動(dòng)態(tài)添加到搜索索引中。
為了將SearchableMixin類整合到Post模型中,我必須將它作為Post的基類,并且還需要監(jiān)聽提交之前和之后的事件:
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()方法來初始化當(dāng)前在數(shù)據(jù)庫中的所有用戶動(dòng)態(tài)的索引:
>>> Post.reindex()
我可以通過運(yùn)行Post.search()來搜索使用SQLAlchemy模型的用戶動(dòng)態(tài)。 在下面的例子中,我要求查詢第一頁的五個(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)來處理用戶動(dòng)態(tài)的自然語言搜索。 所以現(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)擊鏈接就可以訪問搜索結(jié)果。
請(qǐng)?jiān)试S我向你介紹一種區(qū)別于以前的Web表單的處理方式。 我曾經(jīng)使用POST請(qǐng)求來提交表單數(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)用的所有頁面中。
這里是搜索表單類,只有q文本字段:
app/main/forms.py:搜索表單。
from flask import request
class SearchForm(FlaskForm):
q = StringField(_l('Search'), validators=[DataRequired()])
def __init__(self, *args, **kwargs):
if 'formdata' not in kwargs:
kwargs['formdata'] = request.args
if 'csrf_enabled' not in kwargs:
kwargs['csrf_enabled'] = False
super(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)用者沒有提供它們的話)。 formdata參數(shù)決定Flask-WTF從哪里獲取表單提交。缺省情況是使用request.form,這是Flask放置通過POST請(qǐng)求??提交的表單值的地方。通過GET請(qǐng)求提交的表單在查詢字符串中傳遞字段值,所以我需要將Flask-WTF指向request.args,這是Flask寫查詢字符串參數(shù)的地方。你是否還記得的,表單默認(rèn)添加了CSRF保護(hù),包含一個(gè)CSRF標(biāo)記,該標(biāo)記通過模板中的form.hidden_??tag()構(gòu)造添加到表單中。為了使搜索表單運(yùn)作,CSRF需要被禁用,所以我將csrf_enabled設(shè)置為False,以便Flask-WTF知道它需要忽略此表單的CSRF驗(yàn)證。
由于我需要在所有頁面中都顯示此表單,因此無論用戶在查看哪個(gè)頁面,我都需要?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處理程序, 來記錄每個(gè)用戶上次訪問的時(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來專用存儲(chǔ)各個(gè)請(qǐng)求的對(duì)應(yīng)變量。
下一步是將表單渲染成頁面。 我在上面說過,我想在所有頁面中展示這個(gè)表單,所以更有意義的是將其作為導(dǎo)航欄的一部分進(jìn)行渲染。 事實(shí)上,這很簡單,因?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)槟承╉撁妫ㄈ珏e(cuò)誤頁面)可能沒有定義它。 這個(gè)表單與我之前做過的略有不同。 我將method屬性設(shè)置為get,因?yàn)槲蚁M韱螖?shù)據(jù)作為查詢字符串,通過GET請(qǐng)求提交。 另外,我創(chuàng)建的其他表單action屬性為空,因?yàn)樗鼈儽惶峤坏戒秩颈韱蔚耐豁撁妗?而這個(gè)表單很特殊,因?yàn)樗霈F(xiàn)在所有頁面中,所以我需要明確告訴它需要提交的地方,這是專門用于處理搜索的新路由。
搜索視圖函數(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 None
prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \
if page > 1 else None
return render_template('search.html', title=_('Search'), posts=posts,
next_url=next_url, prev_url=prev_url)
你已經(jīng)看到,在其他表單中,我使用form.validate_on_submit()方法來檢查表單提交是否有效。 不幸的是,該方法只適用于通過POST請(qǐng)求提交的表單,所以對(duì)于這個(gè)表單,我需要使用form.validate(),它只驗(yàn)證字段值,而不檢查數(shù)據(jù)是如何提交的。 如果驗(yàn)證失敗,這是因?yàn)橛脩籼峤涣艘粋€(gè)空的搜索表單,所以在這種情況下,我只能重定向到了顯示所有用戶動(dòng)態(tài)的發(fā)現(xiàn)頁面。
SearchableMixin類中的Post.search()方法用于獲取搜索結(jié)果列表。 分頁的處理方式與主頁和發(fā)現(xiàn)頁面非常類似,但如果沒有Flask-SQLAlchemy的“分頁”對(duì)象的幫助,生成下一個(gè)和前一個(gè)鏈接會(huì)有點(diǎn)棘手。 這是從Post.search()返回的結(jié)果總數(shù)的用途所在。
一旦計(jì)算出搜索結(jié)果和分頁鏈接的頁面,剩下的就是渲染一個(gè)包含所有這些數(shù)據(jù)的模板。 我已經(jīng)想出了一種重用index.html模板來顯示搜索結(jié)果的方法,但考慮到有一些差異,我決定創(chuàng)建一個(gè)專用于顯示搜索結(jié)果的search.html專屬模板, 以_post.html子模板的優(yōu)勢(shì)來渲染搜索結(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)混亂,可能查看分頁組件的Bootstrap文檔會(huì)有所幫助。

感想如何? 本章的內(nèi)容有些激進(jìn),因?yàn)槔锩娼榻B了一些相當(dāng)先進(jìn)的技術(shù)。 本章中的一些概念可能需要你花一些時(shí)間才能有所領(lǐng)悟。本章最重要的一點(diǎn)是,如果你想使用與Elasticsearch不同的搜索引擎,只需要重寫app/search.py即可。 通過這項(xiàng)工作的另一個(gè)重要好處是,如果我需要為另外的數(shù)據(jù)庫模型添加搜索支持,我可以簡單地通過向它添加SearchableMixin類,為__searchable__屬性填寫要索引的字段列表和SQLAlchemy事件處理程序的監(jiān)聽即可。 我認(rèn)為這些努力是值得的,因?yàn)閺默F(xiàn)在起,處理全文索引將會(huì)變得十分容易。