Flask 教程 第十六章:全文搜索
本文轉載自:https://www.jianshu.com/p/56cfc972d372
這是Flask Mega-Tutorial系列的第十六部分,我將在其中為Microblog添加全文搜索功能。
本章的目標是為Microblog實現搜索功能,以便用戶可以使用自然語言查找有趣的用戶動態內容。許多不同類型的網站,都可以使用Google,Bing等搜索引擎來索引所有內容,并通過其搜索API提供搜索結果。 這這方法適用于靜態頁面較多的的大部分網站,比如論壇。 但在我的應用中,基本的內容單元是一條用戶動態,它是整個網頁的很小一部分。 我想要的搜索結果的類型是針對這些單獨的用戶動態而不是整個頁面。 例如,如果我搜索單詞“dog”,我想查看任何用戶發表的包含該單詞的動態。 很明顯,顯示所有包含“dog”(或任何其他可能的搜索字詞)的用戶動態的頁面并不存在,大型搜索引擎也就無法索引到它。所以,我別無選擇,只能自己實現搜索功能。
本章的GitHub鏈接為:Browse,?Zip,?Diff.
全文搜索引擎簡介
對于全文搜索的支持不像關系數據庫那樣是標準化的。 有幾種開源的全文搜索引擎:Elasticsearch,Apache Solr,Whoosh,Xapian,Sphinx等等,如果這還不夠,常用的數據庫也可以像我上面列舉的那些專用搜索引擎一樣提供搜索服務。?SQLite,MySQL和PostgreSQL都提供了對搜索文本的支持,以及MongoDB和CouchDB等NoSQL數據庫當然也提供這樣的功能。
如果你想知道哪些應用程序可以在Flask應用中運行,那么答案就是所有! 這是Flask的強項之一,它在完成工作的同時不會自作主張。 那么到底選擇哪一個呢?
在專用搜索引擎列表中,Elasticsearch非常流行,部分原因是它在ELK棧中是用于索引日志的“E”,另兩個是Logstash和Kibana。 使用某個關系數據庫的搜索能力也是一個不錯的選擇,但考慮到SQLAlchemy不支持這種功能,我將不得不使用原始SQL語句來處理搜索,否則就需要一個包, 它提供一個文本搜索的高級接口,并與SQLAlchemy共存。
基于上述分析,我將使用Elasticsearch,但我將以一種非常容易切換到另一個搜索引擎的方式來實現所有文本索引和搜索功能。 你可以用其他搜索引擎的替代替換我的實現,只需在單個模塊中重寫一些函數即可。
安裝Elasticsearch
有幾種方法可以安裝Elasticsearch,包括一鍵安裝程序,帶有需要自行安裝的二進制程序的zip包,甚至是Docker鏡像。 該文檔有一個安裝頁面,其中包含所有這些安裝選項的詳細信息。 如果你使用Linux,你可能會有一個可用于你的發行版的軟件包。 如果你使用的是Mac并安裝了Homebrew,那么你可以簡單地運行brew install elasticsearch。
在計算機上安裝Elasticsearch后,你可以在瀏覽器的地址欄中輸入http://localhost:9200來驗證它是否正在運行,預期的返回結果是JSON格式的服務基本信息。
由于我使用Python來管理Elasticsearch,因此我會使用其對應的Python客戶端庫:
(venv) $ pip install elasticsearch當然不要忘記更新requirements.txt文件:
(venv) $ pip freeze > requirements.txtElasticsearch入門
我將在Python shell中為你展示使用Elasticsearch的基礎知識。 這將幫助你熟悉這項服務,以便了解稍后將討論的實現部分。
要建立與Elasticsearch的連接,需要創建一個Elasticsearch類的實例,并將連接URL作為參數傳遞:
>>> from elasticsearch import Elasticsearch >>> es = Elasticsearch('http://localhost:9200')Elasticsearch中的數據需要被寫入索引中。 與關系數據庫不同,數據只是一個JSON對象。 以下示例將一個包含text字段的對象寫入名為my_index的索引:
>>> es.index(index='my_index', doc_type='my_index', id=1, body={'text': 'this is a test'})如果需要,索引可以存儲不同類型的文檔,在本處,可以根據不同的格式將doc_type參數設置為不同的值。 我要將所有文檔存儲為相同的格式,因此我將文檔類型設置為索引名稱。
對于存儲的每個文檔,Elasticsearch使用了一個唯一的ID來索引含有數據的JSON對象。
讓我們在這個索引上存儲第二個文檔:
>>> es.index(index='my_index', doc_type='my_index', id=2, body={'text': 'a second test'})現在,該索引中有兩個文檔,我可以發布自由格式的搜索。 在本例中,我要搜索this test:
>>> es.search(index='my_index', doc_type='my_index', ... body={'query': {'match': {'text': 'this test'}}})來自es.search()調用的響應是一個包含搜索結果的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'}}]} }在結果中你可以看到搜索返回了兩個文檔,每個文檔都有一個分配的分數。 分數最高的文檔包含我搜索的兩個單詞,而另一個文檔只包含一個單詞。 你可以看到,即使是最好的結果的分數也不是很高,因為這些單詞與文本不是完全一致的。
現在,如果我搜索單詞second,結果如下:
>>> 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'}}]} }我仍然得到相當低的分數,因為我的搜索與文檔中的文本不匹配,但由于這兩個文檔中只有一個包含“second”這個詞,所以不匹配的根本不顯示。
Elasticsearch查詢對象有更多的選項,并且很好地進行了文檔化,其中包含諸如分頁和排序這樣的和關系數據庫一樣的功能。
隨意為此索引添加更多條目并嘗試不同的搜索。 完成試驗后,可以使用以下命令刪除索引:
>>> es.indices.delete('my_index')Elasticsearch配置
將Elasticsearch集成到本應用是展現Flask魅力的絕佳范例。 這是一個與Flask沒有任何關系的服務和Python包,然而,我將從配置開始將它們恰如其分地集成,我先在app.config模塊中實現這樣的操作:
config.py:Elasticsearch 配置。
class Config(object):# ...ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')與許多其他配置條目一樣,Elasticsearch的連接URL將來自環境變量。 如果變量未定義,我將設置其為None,并將其用作禁用Elasticsearch的信號。 這主要是為了方便起見,所以當你運行應用時,尤其是在運行單元測試時,不必強制Elasticsearch服務啟動和運行。 因此,為了確保服務的可用性,我需要直接在終端中定義ELASTICSEARCH_URL環境變量,或者將它添加到 .env 文件中,如下所示:
ELASTICSEARCH_URL=http://localhost:9200使用Elasticsearch面臨著非Flask插件如何使用的挑戰。 我不能像在上面的例子中那樣在全局范圍內創建Elasticsearch實例,因為要初始化它,我需要訪問app.config,它必須在調用create_app()函數后才可用。 所以我決定在應用程序工廠函數中為app實例添加一個elasticsearch屬性:
app/__init__.py:Elasticsearch實例。
# ... 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實例添加一個新屬性可能看起來有點奇怪,但是Python對象在結構上并不嚴格,可以隨時添加新屬性。 你也可以考慮另一種方法,就是定義一個從Flask派生的子類(可以叫Microblog),然后在它的__init__()函數中定義elasticsearch屬性。
請留意我設計的條件表達式,如果Elasticsearch服務的URL在環境變量中未定義,則賦值None給app.elasticsearch。
全文搜索抽象化
正如我在本章的介紹中所說的,我希望能夠輕松地從Elasticsearch切換到其他搜索引擎,并且我也不希望將此功能專門用于搜索用戶動態,我更愿意設計一個可復用的解決方案,如果需要,我可以輕松擴展到其他模型。 出于所有這些原因,我決定將搜索功能抽象化。 我的想法是以通用條件來設計特性,所以不會假設Post模型是唯一需要編制索引的模型,也不會假設Elasticsearch是唯一選擇的搜索引擎。 但是如果我不能對任何事情做出任何假設,我是不可能完成這項工作的!
我需要的做的第一件事,是找到一種通用的方式來指定哪個模型以及其中的某個或某些字段將被索引。 我設定任何需要索引的模型都需要定義一個__searchable__屬性,它列出了需要包含在索引中的字段。 對于Post模型來說,變化如下:
app/models.py: 為Post模型添加一個__searchable__屬性。
class Post(db.Model):__searchable__ = ['body']# ...需要說明的是,這個模型需要有body字段才能被索引。 不過,為了清楚地確保這一點,我添加的這個__searchable__屬性只是一個變量,它沒有任何關聯的行為。 它只會幫助我以通用的方式編寫索引函數。
我將在app/search.py模塊中編寫與Elasticsearch索引交互的所有代碼。 這么做是為了將所有Elasticsearch代碼限制在這個模塊中。 應用的其余部分將使用這個新模塊中的函數來訪問索引,而不會直接訪問Elasticsearch。 這很重要,因為如果有一天我不再喜歡Elasticsearch并想切換到其他引擎,我所需要做的就是重寫這個模塊中的函數,而應用將繼續像以前一樣工作。
對于本應用,我需要三個與文本索引相關的支持功能:我需要將條目添加到全文索引中,我需要從索引中刪除條目(假設有一天我會支持刪除用戶動態),還有就是我需要執行搜索查詢。 下面是app/search.py模塊,它使用我在Python控制臺中向你展示的功能實現Elasticsearch的這三個函數:
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']這些函數都是通過檢查app.elasticsearch是否為None開始的,如果是None,則不做任何事情就返回。 當Elasticsearch服務器未配置時,應用會在沒有搜索功能的狀態下繼續運行,不會出現任何錯誤。 這都是為了方便開發或運行單元測試。
這些函數接受索引名稱作為參數。 在傳遞給Elasticsearch的所有調用中,我不僅將這個名稱用作索引名稱,還將其用作文檔類型,一如我在Python控制臺示例中所做的那樣。
添加和刪除索引條目的函數將SQLAlchemy模型作為第二個參數。?add_to_index()函數使用我添加到模型中的__searchable__變量來構建插入到索引中的文檔。 回顧一下,Elasticsearch文檔還需要一個唯一的標識符。 為此,我使用SQLAlchemy模型的id字段,該字段正好是唯一的。 在SQLAlchemy和Elasticsearch使用相同的id值在運行搜索時非常有用,因為它允許我鏈接兩個數據庫中的條目。 我之前沒有提到的一點是,如果你嘗試添加一個帶有現有id的條目,那么Elasticsearch會用新的條目替換舊條目,所以add_to_index()可以用于新建和修改對象。
在remove_from_index()中的es.delete()函數,我之前沒有展示過。 這個函數刪除存儲在給定id下的文檔。 下面是使用相同id鏈接兩個數據庫中條目的便利性的一個很好的例子。
query_index()函數使用索引名稱和文本進行搜索,通過分頁控件,還可以像Flask-SQLAlchemy結果那樣對搜索結果進行分頁。 你已經從Python控制臺中看到了es.search()函數的示例用法。 我在這里發布的調用非常相似,但不是使用match查詢類型,而是使用multi_match,它可以跨多個字段進行搜索。 通過傳遞*的字段名稱,我告訴Elasticsearch查看所有字段,所以基本上我就是搜索了整個索引。 這對于使該函數具有通用性很有用,因為不同的模型在索引中可以具有不同的字段名稱。
es.search()查詢的body參數還包含分頁參數。?from和size參數控制整個結果集的哪些子集需要被返回。 Elasticsearch沒有像Flask-SQLAlchemy那樣提供一個很好的Pagination對象,所以我必須使用分頁數學邏輯來計算from值。
query_index()函數中的return語句有點復雜。 它返回兩個值:第一個是搜索結果的id元素列表,第二個是結果總數。 兩者都從es.search()函數返回的Python字典中獲得。 用于獲取ID列表的表達式,被稱為列表推導式,是Python語言的一個奇妙功能,它允許你將列表從一種格式轉換為另一種格式。 在本例,我使用列表推導式從Elasticsearch提供的更大的結果列表中提取id值。
這樣看起來是否太混亂? 也許從Python控制臺演示這些函數可以幫助你更好地理解它們。 在接下來的會話中,我手動將數據庫中的所有用戶動態添加到Elasticsearch索引。 在我的測試數據庫中,我有幾條用戶動態中包含數字“one”,“two”, “three”, “four” 和“five”,因此我將其用作搜索查詢。 你可能需要調整你的查詢以匹配數據庫的內容:
>>> 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)我發出的查詢返回了七個結果。 當我以每頁100項查詢第1頁時,我得到了全部的七項,但接下來的三個例子顯示了我如何以與Flask-SQLAlchemy類似的方式對結果進行分頁,當然,結果是ID列表而不是SQLAlchemy對象。
如果你想保持數據的清潔,可以在做實驗之后刪除posts索引:
>>> app.elasticsearch.indices.delete('posts')集成SQLAlchemy到搜索
我在前面的章節中給出的解決方案是可行的,但它仍然存在一些問題。 最明顯的問題是結果是以數字ID列表的形式出現的。 這非常不方便,我需要SQLAlchemy模型,以便我可以將它們傳遞給模板進行渲染,并且我需要用數據庫中相應模型替換數字列表的方法。 第二個問題是,這個解決方案需要應用在添加或刪除用戶動態時明確地發出對應的索引調用,這并非不可行,但并不理想,因為在SQLAlchemy側進行更改時錯過索引調用的情況是不容易被檢測到的,每當發生這種情況時,兩個數據庫就會越來越不同步,并且你可能在一段時間內都不會注意到。 更好的解決方案是在SQLAlchemy數據庫進行更改時自動觸發這些調用。
用對象替換ID的問題可以通過創建一個從數據庫讀取這些對象的SQLAlchemy查詢來解決。 這在實踐中聽起來很容易,但是使用單個查詢來高效地實現它實際上有點棘手。
對于自動觸發索引更改的問題,我決定用SQLAlchemy?事件驅動Elasticsearch索引的更新。 SQLAlchemy提供了大量的事件,可以通知應用程序。 例如,每次提交會話時,我都可以定義一個由SQLAlchemy調用的函數,并且在該函數中,我可以將SQLAlchemy會話中的更新應用于Elasticsearch索引。
為了實現這兩個問題的解決方案,我將編寫mixin類。 記得mixin類嗎? 在第五章中,我將Flask-Login中的UserMixin類添加到了User模型,為它提供Flask-Login所需的一些功能。 對于搜索支持,我將定義我自己的SearchableMixin類,當它被添加到模型時,可以自動管理與SQLAlchemy模型關聯的全文索引。 mixin類將充當SQLAlchemy和Elasticsearch世界之間的“粘合”層,為我上面提到的兩個問題提供解決方案。
讓我先告訴你實現,然后再來回顧一些有趣的細節。 請注意,這使用了多種先進技術,因此你需要仔細研究此代碼以充分理解它。
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)這個mixin類有四個函數,都是類方法。復習一下,類方法是與類相關聯的特殊方法,而不是實例的。 請注意,我將常規實例方法中使用的self參數重命名為cls,以明確此方法接收的是類而不是實例作為其第一個參數。 例如,一旦連接到Post模型,上面的search()方法將被調用為Post.search(),而不必將其實例化。
search()類方法封裝來自app/search.py??的query_index()函數以將對象ID列表替換成實例對象。你可以看到這個函數做的第一件事就是調用query_index(),并傳遞cls .__tablename__作為索引名稱。這將是一個約定,所有索引都將用Flask-SQLAlchemy模型關聯的表名。該函數返回結果ID列表和結果總數。通過它們的ID檢索對象列表的SQLAlchemy查詢基于SQL語言的CASE語句,該語句需要用于確保數據庫中的結果與給定ID的順序相同。這很重要,因為Elasticsearch查詢返回的結果不是有序的。如果你想了解更多關于這個查詢的工作方式,你可以參考這個StackOverflow問題的接受答案。search()函數返回替換ID列表的查詢結果集,以及搜索結果的總數。
before_commit()和after_commit()方法分別對應來自SQLAlchemy的兩個事件,這兩個事件分別在提交發生之前和之后觸發。 前置處理功能很有用,因為會話還沒有提交,所以我可以查看并找出將要添加,修改和刪除的對象,如session.new,session.dirty和session.deleted。 這些對象在會話提交后不再可用,所以我需要在提交之前保存它們。 我使用session._changes字典將這些對象寫入會話提交后仍然存在的地方,因為一旦會話被提交,我將使用它們來更新Elasticsearch索引。
當調用after_commit()處理程序時,會話已成功提交,因此這是在Elasticsearch端進行更新的適當時間。 session對象具有before_commit()中添加的_changes變量,所以現在我可以迭代需要被添加,修改和刪除的對象,并對app/search.py中的索引函數進行相應的調用。
reindex()類方法是一個簡單的幫助方法,你可以使用它來刷新所有數據的索引。 你看到我在上面做的將所有用戶動態初始加載到測試索引中,這個操作與Python shell會話中的類似。 有了這個方法,我可以調用Post.reindex()將數據庫中的所有用戶動態添加到搜索索引中。
為了將SearchableMixin類整合到Post模型中,我必須將它作為Post的基類,并且還需要監聽提交之前和之后的事件:
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)請注意,db.event.listen()調用不在類內部,而是在其后面。 這兩行代碼設置了每次提交之前和之后調用的事件處理程序。 現在Post模型會自動為用戶動態維護一個全文搜索索引。 我可以使用reindex()方法來初始化當前在數據庫中的所有用戶動態的索引:
>>> Post.reindex()我可以通過運行Post.search()來搜索使用SQLAlchemy模型的用戶動態。 在下面的例子中,我要求查詢第一頁的五個元素:
>>> 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>]搜索表單
的確有些激進。 我上面做的保持通用性的工作涉及到幾個高級主題,因此可能需要一些時間才能完全理解。 現在我有一套完整的系統來處理用戶動態的自然語言搜索。 所以現在需要做的是將所有這些功能與應用集成在一起。
基于網絡搜索的一種相當標準的方法是在URL的查詢字符串中將搜索詞作為q參數的值。 例如,如果你想在Google上搜索Python,并且想要節約少許時間,則只需在瀏覽器的地址欄中輸入以下URL即可直接查看結果:
https://www.google.com/search?q=python允許將搜索完全封裝在URL中是很好的,因為這方便了與其他人共享,只要點擊鏈接就可以訪問搜索結果。
請允許我向你介紹一種區別于以前的Web表單的處理方式。 我曾經使用POST請求來提交表單數據,但是為了實現上述搜索,表單提交必須以GET請求發送,這是一種請求方法,當你在瀏覽器中輸入網址或點擊鏈接時,就是GET請求。 另一個有趣的區別是搜索表單將存在于導航欄中,因此它將會出現應用的所有頁面中。
這里是搜索表單類,只有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字段不需要任何解釋,因為它與我以前使用的其他文本字段相似。在這個表單中,我不需要提交按鈕。對于具有文本字段的表單,當焦點位于該字段上時,你按下Enter鍵,瀏覽器將提交表單,因此不需要按鈕。我還添加了一個__init__構造函數,它提供了formdata和csrf_enabled參數的值(如果調用者沒有提供它們的話)。?formdata參數決定Flask-WTF從哪里獲取表單提交。缺省情況是使用request.form,這是Flask放置通過POST請求??提交的表單值的地方。通過GET請求提交的表單在查詢字符串中傳遞字段值,所以我需要將Flask-WTF指向request.args,這是Flask寫查詢字符串參數的地方。你是否還記得的,表單默認添加了CSRF保護,包含一個CSRF標記,該標記通過模板中的form.hidden_??tag()構造添加到表單中。為了使搜索表單運作,CSRF需要被禁用,所以我將csrf_enabled設置為False,以便Flask-WTF知道它需要忽略此表單的CSRF驗證。
由于我需要在所有頁面中都顯示此表單,因此無論用戶在查看哪個頁面,我都需要創建一個SearchForm類的實例。 唯一的要求是用戶登錄,因為對于匿名用戶,我目前不會顯示任何內容。 與其在每個路由中創建表單對象,然后將表單傳遞給所有模板,我將向你展示一個非常有用的技巧,當你需要在整個應用中實現一個功能時,可以消除重復代碼。 回到第六章,我已經使用了before_request處理程序, 來記錄每個用戶上次訪問的時間。 我要做的是在同樣的功能中創建我的搜索表單,但有一點區別:
app/main/routes.py:在請求處理前的處理器中初始化搜索表單。
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())在這里,當用戶已認證時,我會創建一個搜索表單類的實例。當然,我需要這個表單對象一直存在,直到它可以在請求結束時渲染,所以我需要將它存儲在某個地方。那個地方就是Flask提供的g容器。這個g變量是應用可以存儲需要在整個請求期間持續存在的數據的地方。在這里,我將表單存儲在g.search_form中,所以當請求前置處理程序結束并且Flask調用處理請求的URL的視圖函數時,g對象將會是相同的,并且表單仍然存在。請注意,這個g變量對每個請求和每個客戶端都是特定的,因此即使你的Web服務器一次為不同的客戶端處理多個請求,仍然可以依靠g來專用存儲各個請求的對應變量。
下一步是將表單渲染成頁面。 我在上面說過,我想在所有頁面中展示這個表單,所以更有意義的是將其作為導航欄的一部分進行渲染。 事實上,這很簡單,因為模板也可以看到存儲在g變量中的數據,所以我不需要在所有render_template()調用中將表單作為顯式模板參數添加進去。以下是我如何在基礎模板中渲染表單的代碼:
app/templates/base.html:在導航欄中渲染搜索表單。
...<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時才會渲染表單。 此檢查是必要的,因為某些頁面(如錯誤頁面)可能沒有定義它。 這個表單與我之前做過的略有不同。 我將method屬性設置為get,因為我希望表單數據作為查詢字符串,通過GET請求提交。 另外,我創建的其他表單action屬性為空,因為它們被提交到渲染表單的同一頁面。 而這個表單很特殊,因為它出現在所有頁面中,所以我需要明確告訴它需要提交的地方,這是專門用于處理搜索的新路由。
搜索視圖函數
完成搜索功能的最后一項功能是接收搜索表單的視圖函數。 該視圖函數將被附加到/search路由,以便你可以發送類似http://localhost:5000/search?q=search-words的搜索請求,就像Google一樣。
app/main/routes.py:搜索視圖函數。
@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)你已經看到,在其他表單中,我使用form.validate_on_submit()方法來檢查表單提交是否有效。 不幸的是,該方法只適用于通過POST請求提交的表單,所以對于這個表單,我需要使用form.validate(),它只驗證字段值,而不檢查數據是如何提交的。 如果驗證失敗,這是因為用戶提交了一個空的搜索表單,所以在這種情況下,我只能重定向到了顯示所有用戶動態的發現頁面。
SearchableMixin類中的Post.search()方法用于獲取搜索結果列表。 分頁的處理方式與主頁和發現頁面非常類似,但如果沒有Flask-SQLAlchemy的“分頁”對象的幫助,生成下一個和前一個鏈接會有點棘手。 這是從Post.search()返回的結果總數的用途所在。
一旦計算出搜索結果和分頁鏈接的頁面,剩下的就是渲染一個包含所有這些數據的模板。 我已經想出了一種重用index.html模板來顯示搜索結果的方法,但考慮到有一些差異,我決定創建一個專用于顯示搜索結果的search.html專屬模板, 以_post.html子模板的優勢來渲染搜索結果:
app/templates/search.html:搜索結果模板。
{% 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 %}如果前一個和下一個鏈接的渲染邏輯有點混亂,可能查看分頁組件的Bootstrap文檔會有所幫助。
感想如何? 本章的內容有些激進,因為里面介紹了一些相當先進的技術。 本章中的一些概念可能需要你花一些時間才能有所領悟。本章最重要的一點是,如果你想使用與Elasticsearch不同的搜索引擎,只需要重寫app/search.py即可。 通過這項工作的另一個重要好處是,如果我需要為另外的數據庫模型添加搜索支持,我可以簡單地通過向它添加SearchableMixin類,為__searchable__屬性填寫要索引的字段列表和SQLAlchemy事件處理程序的監聽即可。 我認為這些努力是值得的,因為從現在起,處理全文索引將會變得十分容易。
總結
以上是生活随笔為你收集整理的Flask 教程 第十六章:全文搜索的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++ 常用函数总结
- 下一篇: Oracle的PL/SQL编程前奏之基础