使用 Docker 和 Elasticsearch 構建一個全文搜索應用程序
如何在超過 500 萬篇文章的 Wikipedia 上找到與你研究相關的文章?
如何在超過 20 億用戶的 Facebook 中找到你的朋友(並且還拼錯了名字)?
谷歌如何在整個網際網路上搜索你的模糊的、充滿拼寫錯誤的查詢?
在本教程中,我們將帶你探索如何配置我們自己的全文搜索應用程序(與上述問題中的系統相比,它的複雜度要小很多)。我們的示例應用程序將提供一個 UI 和 API 去從 100 部經典文學(比如,《彼得·潘》 、 《弗蘭肯斯坦》 和 《金銀島》)中搜索完整的文本。
你可以在這裡(https://search.patricktriest.com)預覽該教程應用的完整版本。
這個應用程序的源代碼是 100% 開源的,可以在 GitHub 倉庫上找到它們 —— https://github.com/triestpa/guttenberg-search 。
在應用程序中添加一個快速靈活的全文搜索可能是個挑戰。大多數的主流資料庫,比如,PostgreSQL 和 MongoDB,由於受其查詢和索引結構的限制只能提供一個非常基礎的文本搜索功能。為實現高質量的全文搜索,通常的最佳選擇是單獨的數據存儲。Elasticsearch 是一個開源數據存儲的領導者,它專門為執行靈活而快速的全文搜索進行了優化。
我們將使用 Docker 去配置我們自己的項目環境和依賴。Docker 是一個容器化引擎,它被 Uber、Spotify、ADP 以及 Paypal 使用。構建容器化應用的一個主要優勢是,項目的設置在 Windows、macOS、以及 Linux 上都是相同的 —— 這使我寫這個教程快速又簡單。如果你還沒有使用過 Docker,不用擔心,我們接下來將經歷完整的項目配置。
我也會使用 Node.js (使用 Koa 框架)和 Vue.js,用它們分別去構建我們自己的搜索 API 和前端 Web 應用程序。
1 - Elasticsearch 是什麼?
全文搜索在現代應用程序中是一個有大量需求的特性。搜索也可能是最難的一項特性 —— 許多流行的網站的搜索功能都不合格,要麼返回結果太慢,要麼找不到精確的結果。通常,這種情況是被底層的資料庫所局限:大多數標準的關係型資料庫局限於基本的 CONTAINS
或 LIKE
SQL 查詢上,它僅提供最基本的字元串匹配功能。
我們的搜索應用程序將具備:
- 快速 - 搜索結果將快速返回,為用戶提供一個良好的體驗。
- 靈活 - 我們希望能夠去修改搜索如何執行的方式,這是為了便於在不同的資料庫和用戶場景下進行優化。
- 容錯 - 如果所搜索的內容有拼寫錯誤,我們將仍然會返回相關的結果,而這個結果可能正是用戶希望去搜索的結果。
- 全文 - 我們不想限制我們的搜索只能與指定的關鍵字或者標籤相匹配 —— 我們希望它可以搜索在我們的數據存儲中的任何東西(包括大的文本欄位)。
為了構建一個功能強大的搜索功能,通常最理想的方法是使用一個為全文搜索任務優化過的數據存儲。在這裡我們使用 Elasticsearch,Elasticsearch 是一個開源的內存中的數據存儲,它是用 Java 寫的,最初是在 Apache Lucene 庫上構建的。
這裡有一些來自 Elastic 官方網站 上的 Elasticsearch 真實使用案例。
- Wikipedia 使用 Elasticsearch 去提供帶高亮搜索片斷的全文搜索功能,並且提供按類型搜索和 「did-you-mean」 建議。
- Guardian 使用 Elasticsearch 把社交網路數據和訪客日誌相結合,為編輯去提供新文章的公眾意見的實時反饋。
- Stack Overflow 將全文搜索和地理查詢相結合,並使用 「類似」 的方法去找到相關的查詢和回答。
- GitHub 使用 Elasticsearch 對 1300 億行代碼進行查詢。
與 「普通的」 資料庫相比,Elasticsearch 有什麼不一樣的地方?
Elasticsearch 之所以能夠提供快速靈活的全文搜索,秘密在於它使用 反轉索引 。
「索引」 是資料庫中的一種數據結構,它能夠以超快的速度進行數據查詢和檢索操作。資料庫通過存儲與表中行相關聯的欄位來生成索引。在一種可搜索的數據結構(一般是 B 樹)中排序索引,在優化過的查詢中,資料庫能夠達到接近線性的時間(比如,「使用 ID=5 查找行」)。
我們可以將資料庫索引想像成一個圖書館中老式的卡片式目錄 —— 只要你知道書的作者和書名,它就會告訴你書的準確位置。為加速特定欄位上的查詢速度,資料庫表一般有多個索引(比如,在 name
列上的索引可以加速指定名字的查詢)。
反轉索引本質上是不一樣的。每行(或文檔)的內容是分開的,並且每個獨立的條目(在本案例中是單詞)反向指向到包含它的任何文檔上。
這種反轉索引數據結構可以使我們非常快地查詢到,所有出現 「football」 的文檔。通過使用大量優化過的內存中的反轉索引,Elasticsearch 可以讓我們在存儲的數據上,執行一些非常強大的和自定義的全文搜索。
2 - 項目設置
2.0 - Docker
我們在這個項目上使用 Docker 管理環境和依賴。Docker 是個容器引擎,它允許應用程序運行在一個獨立的環境中,不會受到來自主機操作系統和本地開發環境的影響。現在,許多公司將它們的大規模 Web 應用程序主要運行在容器架構上。這樣將提升靈活性和容器化應用程序組件的可組構性。
對我來說,使用 Docker 的優勢是,它對本教程的作者非常方便,它的本地環境設置量最小,並且跨 Windows、macOS 和 Linux 系統的一致性很好。我們只需要在 Docker 配置文件中定義這些依賴關係,而不是按安裝說明分別去安裝 Node.js、Elasticsearch 和 Nginx,然後,就可以使用這個配置文件在任何其它地方運行我們的應用程序。而且,因為每個應用程序組件都運行在它自己的獨立容器中,它們受本地機器上的其它 「垃圾」 干擾的可能性非常小,因此,在調試問題時,像「它在我這裡可以工作!」這類的問題將非常少。
2.1 - 安裝 Docker & Docker-Compose
這個項目只依賴 Docker 和 docker-compose,docker-compose 是 Docker 官方支持的一個工具,它用來將定義的多個容器配置 組裝 成單一的應用程序棧。
- 安裝 Docker - https://docs.docker.com/engine/installation/
- 安裝 Docker Compose - https://docs.docker.com/compose/install/
2.2 - 設置項目主目錄
為項目創建一個主目錄(名為 guttenberg_search
)。我們的項目將工作在主目錄的以下兩個子目錄中。
/public
- 保存前端 Vue.js Web 應用程序。/server
- 伺服器端 Node.js 源代碼。
2.3 - 添加 Docker-Compose 配置
接下來,我們將創建一個 docker-compose.yml
文件來定義我們的應用程序棧中的每個容器。
gs-api
- 後端應用程序邏輯使用的 Node.js 容器gs-frontend
- 前端 Web 應用程序使用的 Ngnix 容器。gs-search
- 保存和搜索數據的 Elasticsearch 容器。
version: '3'
services:
api: # Node.js App
container_name: gs-api
build: .
ports:
- "3000:3000" # Expose API port
- "9229:9229" # Expose Node process debug port (disable in production)
environment: # Set ENV vars
- NODE_ENV=local
- ES_HOST=elasticsearch
- PORT=3000
volumes: # Attach local book data directory
- ./books:/usr/src/app/books
frontend: # Nginx Server For Frontend App
container_name: gs-frontend
image: nginx
volumes: # Serve local "public" dir
- ./public:/usr/share/nginx/html
ports:
- "8080:80" # Forward site to localhost:8080
elasticsearch: # Elasticsearch Instance
container_name: gs-search
image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
volumes: # Persist ES data in seperate "esdata" volume
- esdata:/usr/share/elasticsearch/data
environment:
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- discovery.type=single-node
ports: # Expose Elasticsearch ports
- "9300:9300"
- "9200:9200"
volumes: # Define seperate volume for Elasticsearch data
esdata:
這個文件定義了我們全部的應用程序棧 —— 不需要在你的本地系統上安裝 Elasticsearch、Node 和 Nginx。每個容器都將埠轉發到宿主機系統(localhost
)上,以便於我們在宿主機上去訪問和調試 Node API、Elasticsearch 實例和前端 Web 應用程序。
2.4 - 添加 Dockerfile
對於 Nginx 和 Elasticsearch,我們使用了官方預構建的鏡像,而 Node.js 應用程序需要我們自己去構建。
在應用程序的根目錄下定義一個簡單的 Dockerfile
配置文件。
# Use Node v8.9.0 LTS
FROM node:carbon
# Setup app working directory
WORKDIR /usr/src/app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install app dependencies
RUN npm install
# Copy sourcecode
COPY . .
# Start app
CMD [ "npm", "start" ]
這個 Docker 配置擴展了官方的 Node.js 鏡像、拷貝我們的應用程序源代碼、以及在容器內安裝 NPM 依賴。
我們也增加了一個 .dockerignore
文件,以防止我們不需要的文件拷貝到容器中。
node_modules/
npm-debug.log
books/
public/
請注意:我們之所以不拷貝
node_modules
目錄到我們的容器中 —— 是因為我們要在容器構建過程裡面運行npm install
。從宿主機系統拷貝node_modules
到容器裡面可能會引起錯誤,因為一些包需要為某些操作系統專門構建。比如說,在 macOS 上安裝bcrypt
包,然後嘗試將這個模塊直接拷貝到一個 Ubuntu 容器上將不能工作,因為bcyrpt
需要為每個操作系統構建一個特定的二進位文件。
2.5 - 添加基本文件
為了測試我們的配置,我們需要添加一些佔位符文件到應用程序目錄中。
在 public/index.html
文件中添加如下內容。
<html><body>Hello World From The Frontend Container</body></html>
接下來,在 server/app.js
中添加 Node.js 佔位符文件。
const Koa = require('koa')
const app = new Koa()
app.use(async (ctx, next) => {
ctx.body = 'Hello World From the Backend Container'
})
const port = process.env.PORT || 3000
app.listen(port, err => {
if (err) console.error(err)
console.log(`App Listening on Port ${port}`)
})
最後,添加我們的 package.json
Node 應用配置。
{
"name": "guttenberg-search",
"version": "0.0.1",
"description": "Source code for Elasticsearch tutorial using 100 classic open source books.",
"scripts": {
"start": "node --inspect=0.0.0.0:9229 server/app.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/triestpa/guttenberg-search.git"
},
"author": "patrick.triest@gmail.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/triestpa/guttenberg-search/issues"
},
"homepage": "https://github.com/triestpa/guttenberg-search#readme",
"dependencies": {
"elasticsearch": "13.3.1",
"joi": "13.0.1",
"koa": "2.4.1",
"koa-joi-validate": "0.5.1",
"koa-router": "7.2.1"
}
}
這個文件定義了應用程序啟動命令和 Node.js 包依賴。
注意:不要運行
npm install
—— 當它構建時,依賴會在容器內安裝。
2.6 - 測試它的輸出
現在一切新緒,我們來測試應用程序的每個組件的輸出。從應用程序的主目錄運行 docker-compose build
,它將構建我們的 Node.js 應用程序容器。
接下來,運行 docker-compose up
去啟動整個應用程序棧。
這一步可能需要幾分鐘時間,因為 Docker 要為每個容器去下載基礎鏡像。以後再次運行,啟動應用程序會非常快,因為所需要的鏡像已經下載完成了。
在你的瀏覽器中嘗試訪問 localhost:8080
—— 你將看到簡單的 「Hello World」 Web 頁面。
訪問 localhost:3000
去驗證我們的 Node 伺服器,它將返回 「Hello World」 信息。
最後,訪問 localhost:9200
去檢查 Elasticsearch 運行狀態。它將返回類似如下的內容。
{
"name" : "SLTcfpI",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "iId8e0ZeS_mgh9ALlWQ7-w",
"version" : {
"number" : "6.1.1",
"build_hash" : "bd92e7f",
"build_date" : "2017-12-17T20:23:25.338Z",
"build_snapshot" : false,
"lucene_version" : "7.1.0",
"minimum_wire_compatibility_version" : "5.6.0",
"minimum_index_compatibility_version" : "5.0.0"
},
"tagline" : "You Know, for Search"
}
如果三個 URL 都顯示成功,祝賀你!整個容器棧已經正常運行了,接下來我們進入最有趣的部分。
3 - 連接到 Elasticsearch
我們要做的第一件事情是,讓我們的應用程序連接到我們本地的 Elasticsearch 實例上。
3.0 - 添加 ES 連接模塊
在新文件 server/connection.js
中添加如下的 Elasticsearch 初始化代碼。
const elasticsearch = require('elasticsearch')
// Core ES variables for this project
const index = 'library'
const type = 'novel'
const port = 9200
const host = process.env.ES_HOST || 'localhost'
const client = new elasticsearch.Client({ host: { host, port } })
/** Check the ES connection status */
async function checkConnection () {
let isConnected = false
while (!isConnected) {
console.log('Connecting to ES')
try {
const health = await client.cluster.health({})
console.log(health)
isConnected = true
} catch (err) {
console.log('Connection Failed, Retrying...', err)
}
}
}
checkConnection()
現在,我們重新構建我們的 Node 應用程序,我們將使用 docker-compose build
來做一些改變。接下來,運行 docker-compose up -d
去啟動應用程序棧,它將以守護進程的方式在後台運行。
應用程序啟動之後,在命令行中運行 docker exec gs-api "node" "server/connection.js"
,以便於在容器內運行我們的腳本。你將看到類似如下的系統輸出信息。
{ cluster_name: 'docker-cluster',
status: 'yellow',
timed_out: false,
number_of_nodes: 1,
number_of_data_nodes: 1,
active_primary_shards: 1,
active_shards: 1,
relocating_shards: 0,
initializing_shards: 0,
unassigned_shards: 1,
delayed_unassigned_shards: 0,
number_of_pending_tasks: 0,
number_of_in_flight_fetch: 0,
task_max_waiting_in_queue_millis: 0,
active_shards_percent_as_number: 50 }
繼續之前,我們先刪除最下面的 checkConnection()
調用,因為,我們最終的應用程序將調用外部的連接模塊。
3.1 - 添加函數去重置索引
在 server/connection.js
中的 checkConnection
下面添加如下的函數,以便於重置 Elasticsearch 索引。
/** Clear the index, recreate it, and add mappings */
async function resetIndex (index) {
if (await client.indices.exists({ index })) {
await client.indices.delete({ index })
}
await client.indices.create({ index })
await putBookMapping()
}
3.2 - 添加圖書模式
接下來,我們將為圖書的數據模式添加一個 「映射」。在 server/connection.js
中的 resetIndex
函數下面添加如下的函數。
/** Add book section schema mapping to ES */
async function putBookMapping () {
const schema = {
title: { type: 'keyword' },
author: { type: 'keyword' },
location: { type: 'integer' },
text: { type: 'text' }
}
return client.indices.putMapping({ index, type, body: { properties: schema } })
}
這是為 book
索引定義了一個映射。Elasticsearch 中的 index
大概類似於 SQL 的 table
或者 MongoDB 的 collection
。我們通過添加映射來為存儲的文檔指定每個欄位和它的數據類型。Elasticsearch 是無模式的,因此,從技術角度來看,我們是不需要添加映射的,但是,這樣做,我們可以更好地控制如何處理數據。
比如,我們給 title
和 author
欄位分配 keyword
類型,給 text
欄位分配 text
類型。之所以這樣做的原因是,搜索引擎可以區別處理這些字元串欄位 —— 在搜索的時候,搜索引擎將在 text
欄位中搜索可能的匹配項,而對於 keyword
類型欄位,將對它們進行全文匹配。這看上去差別很小,但是它們對在不同的搜索上的速度和行為的影響非常大。
在文件的底部,導出對外發布的屬性和函數,這樣我們的應用程序中的其它模塊就可以訪問它們了。
module.exports = {
client, index, type, checkConnection, resetIndex
}
4 - 載入原始數據
我們將使用來自 古登堡項目 的數據 —— 它致力於為公共提供免費的線上電子書。在這個項目中,我們將使用 100 本經典圖書來充實我們的圖書館,包括《福爾摩斯探案集》、《金銀島》、《基督山復仇記》、《環遊世界八十天》、《羅密歐與朱麗葉》 和《奧德賽》。
4.1 - 下載圖書文件
我將這 100 本書打包成一個文件,你可以從這裡下載它 —— https://cdn.patricktriest.com/data/books.zip
將這個文件解壓到你的項目的 books/
目錄中。
你可以使用以下的命令來完成(需要在命令行下使用 wget 和 The Unarchiver)。
wget https://cdn.patricktriest.com/data/books.zip
unar books.zip
4.2 - 預覽一本書
嘗試打開其中的一本書的文件,假設打開的是 219-0.txt
。你將注意到它開頭是一個公開訪問的協議,接下來是一些標識這本書的書名、作者、發行日期、語言和字元編碼的行。
Title: Heart of Darkness
Author: Joseph Conrad
Release Date: February 1995 [EBook #219]
Last Updated: September 7, 2016
Language: English
Character set encoding: UTF-8
在 *** START OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS ***
這些行後面,是這本書的正式內容。
如果你滾動到本書的底部,你將看到類似 *** END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS ***
信息,接下來是本書更詳細的協議版本。
下一步,我們將使用程序從文件頭部來解析書的元數據,提取 *** START OF
和 ***END OF
之間的內容。
4.3 - 讀取數據目錄
我們將寫一個腳本來讀取每本書的內容,並將這些數據添加到 Elasticsearch。我們將定義一個新的 Javascript 文件 server/load_data.js
來執行這些操作。
首先,我們將從 books/
目錄中獲取每個文件的列表。
在 server/load_data.js
中添加下列內容。
const fs = require('fs')
const path = require('path')
const esConnection = require('./connection')
/** Clear ES index, parse and index all files from the books directory */
async function readAndInsertBooks () {
try {
// Clear previous ES index
await esConnection.resetIndex()
// Read books directory
let files = fs.readdirSync('./books').filter(file => file.slice(-4) === '.txt')
console.log(`Found ${files.length} Files`)
// Read each book file, and index each paragraph in elasticsearch
for (let file of files) {
console.log(`Reading File - ${file}`)
const filePath = path.join('./books', file)
const { title, author, paragraphs } = parseBookFile(filePath)
await insertBookData(title, author, paragraphs)
}
} catch (err) {
console.error(err)
}
}
readAndInsertBooks()
我們將使用一個快捷命令來重構我們的 Node.js 應用程序,並更新運行的容器。
運行 docker-compose up -d --build
去更新應用程序。這是運行 docker-compose build
和 docker-compose up -d
的快捷命令。
為了在容器中運行我們的 load_data
腳本,我們運行 docker exec gs-api "node" "server/load_data.js"
。你將看到 Elasticsearch 的狀態輸出 Found 100 Books
。
這之後,腳本發生了錯誤退出,原因是我們調用了一個沒有定義的輔助函數(parseBookFile
)。
4.4 - 讀取數據文件
接下來,我們讀取元數據和每本書的內容。
在 server/load_data.js
中定義新函數。
/** Read an individual book text file, and extract the title, author, and paragraphs */
function parseBookFile (filePath) {
// Read text file
const book = fs.readFileSync(filePath, 'utf8')
// Find book title and author
const title = book.match(/^Title:s(.+)$/m)[1]
const authorMatch = book.match(/^Author:s(.+)$/m)
const author = (!authorMatch || authorMatch[1].trim() === '') ? 'Unknown Author' : authorMatch[1]
console.log(`Reading Book - ${title} By ${author}`)
// Find Guttenberg metadata header and footer
const startOfBookMatch = book.match(/^*{3}s*START OF (THIS|THE) PROJECT GUTENBERG EBOOK.+*{3}$/m)
const startOfBookIndex = startOfBookMatch.index + startOfBookMatch[0].length
const endOfBookIndex = book.match(/^*{3}s*END OF (THIS|THE) PROJECT GUTENBERG EBOOK.+*{3}$/m).index
// Clean book text and split into array of paragraphs
const paragraphs = book
.slice(startOfBookIndex, endOfBookIndex) // Remove Guttenberg header and footer
.split(/ns+n/g) // Split each paragraph into it's own array entry
.map(line => line.replace(/rn/g, ' ').trim()) // Remove paragraph line breaks and whitespace
.map(line => line.replace(/_/g, '')) // Guttenberg uses "_" to signify italics. We'll remove it, since it makes the raw text look messy.
.filter((line) => (line && line.length !== '')) // Remove empty lines
console.log(`Parsed ${paragraphs.length} Paragraphsn`)
return { title, author, paragraphs }
}
這個函數執行幾個重要的任務。
- 從文件系統中讀取書的文本。
- 使用正則表達式(關於正則表達式,請參閱 這篇文章 )解析書名和作者。
- 通過匹配 「古登堡項目」 的頭部和尾部,識別書的正文內容。
- 提取書的內容文本。
- 分割每個段落到它的數組中。
- 清理文本並刪除空白行。
它的返回值,我們將構建一個對象,這個對象包含書名、作者、以及書中各段落的數組。
再次運行 docker-compose up -d --build
和 docker exec gs-api "node" "server/load_data.js"
,你將看到輸出同之前一樣,在輸出的末尾有三個額外的行。
成功!我們的腳本從文本文件中成功解析出了書名和作者。腳本再次以錯誤結束,因為到現在為止,我們還沒有定義輔助函數。
4.5 - 在 ES 中索引數據文件
最後一步,我們將批量上傳每個段落的數組到 Elasticsearch 索引中。
在 load_data.js
中添加新的 insertBookData
函數。
/** Bulk index the book data in Elasticsearch */
async function insertBookData (title, author, paragraphs) {
let bulkOps = [] // Array to store bulk operations
// Add an index operation for each section in the book
for (let i = 0; i < paragraphs.length; i++) {
// Describe action
bulkOps.push({ index: { _index: esConnection.index, _type: esConnection.type } })
// Add document
bulkOps.push({
author,
title,
location: i,
text: paragraphs[i]
})
if (i > 0 && i % 500 === 0) { // Do bulk insert in 500 paragraph batches
await esConnection.client.bulk({ body: bulkOps })
bulkOps = []
console.log(`Indexed Paragraphs ${i - 499} - ${i}`)
}
}
// Insert remainder of bulk ops array
await esConnection.client.bulk({ body: bulkOps })
console.log(`Indexed Paragraphs ${paragraphs.length - (bulkOps.length / 2)} - ${paragraphs.length}nnn`)
}
這個函數將使用書名、作者和附加元數據的段落位置來索引書中的每個段落。我們通過批量操作來插入段落,它比逐個段落插入要快的多。
我們分批索引段落,而不是一次性插入全部,是為運行這個應用程序的內存稍有點小(1.7 GB)的伺服器
search.patricktriest.com
上做的一個重要優化。如果你的機器內存還行(4 GB 以上),你或許不用分批上傳。
運行 docker-compose up -d --build
和 docker exec gs-api "node" "server/load_data.js"
一次或多次 —— 現在你將看到前面解析的 100 本書的完整輸出,並插入到了 Elasticsearch。這可能需要幾分鐘時間,甚至更長。
5 - 搜索
現在,Elasticsearch 中已經有了 100 本書了(大約有 230000 個段落),現在我們嘗試搜索查詢。
5.0 - 簡單的 HTTP 查詢
首先,我們使用 Elasticsearch 的 HTTP API 對它進行直接查詢。
在你的瀏覽器上訪問這個 URL - http://localhost:9200/library/_search?q=text:Java&pretty
在這裡,我們將執行一個極簡的全文搜索,在我們的圖書館的書中查找 「Java」 這個詞。
你將看到類似於下面的一個 JSON 格式的響應。
{
"took" : 11,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 13,
"max_score" : 14.259304,
"hits" : [
{
"_index" : "library",
"_type" : "novel",
"_id" : "p_GwFWEBaZvLlaAUdQgV",
"_score" : 14.259304,
"_source" : {
"author" : "Charles Darwin",
"title" : "On the Origin of Species",
"location" : 1080,
"text" : "Java, plants of, 375."
}
},
{
"_index" : "library",
"_type" : "novel",
"_id" : "wfKwFWEBaZvLlaAUkjfk",
"_score" : 10.186235,
"_source" : {
"author" : "Edgar Allan Poe",
"title" : "The Works of Edgar Allan Poe",
"location" : 827,
"text" : "After many years spent in foreign travel, I sailed in the year 18-- , from the port of Batavia, in the rich and populous island of Java, on a voyage to the Archipelago of the Sunda islands. I went as passenger--having no other inducement than a kind of nervous restlessness which haunted me as a fiend."
}
},
...
]
}
}
用 Elasticseach 的 HTTP 介面可以測試我們插入的數據是否成功,但是如果直接將這個 API 暴露給 Web 應用程序將有極大的風險。這個 API 將會暴露管理功能(比如直接添加和刪除文檔),最理想的情況是完全不要對外暴露它。而是寫一個簡單的 Node.js API 去接收來自客戶端的請求,然後(在我們的本地網路中)生成一個正確的查詢發送給 Elasticsearch。
5.1 - 查詢腳本
我們現在嘗試從我們寫的 Node.js 腳本中查詢 Elasticsearch。
創建一個新文件,server/search.js
。
const { client, index, type } = require('./connection')
module.exports = {
/** Query ES index for the provided term */
queryTerm (term, offset = 0) {
const body = {
from: offset,
query: { match: {
text: {
query: term,
operator: 'and',
fuzziness: 'auto'
} } },
highlight: { fields: { text: {} } }
}
return client.search({ index, type, body })
}
}
我們的搜索模塊定義一個簡單的 search
函數,它將使用輸入的詞 match
查詢。
這是查詢的欄位分解 -
from
- 允許我們分頁查詢結果。默認每個查詢返回 10 個結果,因此,指定from: 10
將允許我們取回 10-20 的結果。query
- 這裡我們指定要查詢的詞。operator
- 我們可以修改搜索行為;在本案例中,我們使用and
操作去對查詢中包含所有字元(要查詢的詞)的結果來確定優先順序。fuzziness
- 對拼寫錯誤的容錯調整,auto
的默認為fuzziness: 2
。模糊值越高,結果越需要更多校正。比如,fuzziness: 1
將允許以Patricc
為關鍵字的查詢中返回與Patrick
匹配的結果。highlights
- 為結果返回一個額外的欄位,這個欄位包含 HTML,以顯示精確的文本字集和查詢中匹配的關鍵詞。
你可以去瀏覽 Elastic Full-Text Query DSL,學習如何隨意調整這些參數,以進一步自定義搜索查詢。
6 - API
為了能夠從前端應用程序中訪問我們的搜索功能,我們來寫一個快速的 HTTP API。
6.0 - API 伺服器
用以下的內容替換現有的 server/app.js
文件。
const Koa = require('koa')
const Router = require('koa-router')
const joi = require('joi')
const validate = require('koa-joi-validate')
const search = require('./search')
const app = new Koa()
const router = new Router()
// Log each request to the console
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}`)
})
// Log percolated errors to the console
app.on('error', err => {
console.error('Server Error', err)
})
// Set permissive CORS header
app.use(async (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', '*')
return next()
})
// ADD ENDPOINTS HERE
const port = process.env.PORT || 3000
app
.use(router.routes())
.use(router.allowedMethods())
.listen(port, err => {
if (err) throw err
console.log(`App Listening on Port ${port}`)
})
這些代碼將為 Koa.js Node API 伺服器導入伺服器依賴,設置簡單的日誌,以及錯誤處理。
6.1 - 使用查詢連接端點
接下來,我們將在伺服器上添加一個端點,以便於發布我們的 Elasticsearch 查詢功能。
在 server/app.js
文件的 // ADD ENDPOINTS HERE
下面插入下列的代碼。
/**
* GET /search
* Search for a term in the library
*/
router.get('/search', async (ctx, next) => {
const { term, offset } = ctx.request.query
ctx.body = await search.queryTerm(term, offset)
}
)
使用 docker-compose up -d --build
重啟動應用程序。之後在你的瀏覽器中嘗試調用這個搜索端點。比如,http://localhost:3000/search?term=java
這個請求將搜索整個圖書館中提到 「Java」 的內容。
結果與前面直接調用 Elasticsearch HTTP 界面的結果非常類似。
{
"took": 242,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 93,
"max_score": 13.356944,
"hits": [{
"_index": "library",
"_type": "novel",
"_id": "eHYHJmEBpQg9B4622421",
"_score": 13.356944,
"_source": {
"author": "Charles Darwin",
"title": "On the Origin of Species",
"location": 1080,
"text": "Java, plants of, 375."
},
"highlight": {
"text": ["<em>Java</em>, plants of, 375."]
}
}, {
"_index": "library",
"_type": "novel",
"_id": "2HUHJmEBpQg9B462xdNg",
"_score": 9.030668,
"_source": {
"author": "Unknown Author",
"title": "The King James Bible",
"location": 186,
"text": "10:4 And the sons of Javan; Elishah, and Tarshish, Kittim, and Dodanim."
},
"highlight": {
"text": ["10:4 And the sons of <em>Javan</em>; Elishah, and Tarshish, Kittim, and Dodanim."]
}
}
...
]
}
}
6.2 - 輸入校驗
這個端點現在還很脆弱 —— 我們沒有對請求參數做任何的校驗,因此,如果是無效的或者錯誤的值將使伺服器出錯。
我們將添加一些使用 Joi 和 Koa-Joi-Validate 庫的中間件,以對輸入做校驗。
/**
* GET /search
* Search for a term in the library
* Query Params -
* term: string under 60 characters
* offset: positive integer
*/
router.get('/search',
validate({
query: {
term: joi.string().max(60).required(),
offset: joi.number().integer().min(0).default(0)
}
}),
async (ctx, next) => {
const { term, offset } = ctx.request.query
ctx.body = await search.queryTerm(term, offset)
}
)
現在,重啟伺服器,如果你使用一個沒有搜索關鍵字的請求(http://localhost:3000/search
),你將返回一個帶相關消息的 HTTP 400 錯誤,比如像 Invalid URL Query - child "term" fails because ["term" is required]
。
如果想從 Node 應用程序中查看實時日誌,你可以運行 docker-compose logs -f api
。
7 - 前端應用程序
現在我們的 /search
端點已經就緒,我們來連接到一個簡單的 Web 應用程序來測試這個 API。
7.0 - Vue.js 應用程序
我們將使用 Vue.js 去協調我們的前端。
添加一個新文件 /public/app.js
,去控制我們的 Vue.js 應用程序代碼。
const vm = new Vue ({
el: '#vue-instance',
data () {
return {
baseUrl: 'http://localhost:3000', // API url
searchTerm: 'Hello World', // Default search term
searchDebounce: null, // Timeout for search bar debounce
searchResults: [], // Displayed search results
numHits: null, // Total search results found
searchOffset: 0, // Search result pagination offset
selectedParagraph: null, // Selected paragraph object
bookOffset: 0, // Offset for book paragraphs being displayed
paragraphs: [] // Paragraphs being displayed in book preview window
}
},
async created () {
this.searchResults = await this.search() // Search for default term
},
methods: {
/** Debounce search input by 100 ms */
onSearchInput () {
clearTimeout(this.searchDebounce)
this.searchDebounce = setTimeout(async () => {
this.searchOffset = 0
this.searchResults = await this.search()
}, 100)
},
/** Call API to search for inputted term */
async search () {
const response = await axios.get(`${this.baseUrl}/search`, { params: { term: this.searchTerm, offset: this.searchOffset } })
this.numHits = response.data.hits.total
return response.data.hits.hits
},
/** Get next page of search results */
async nextResultsPage () {
if (this.numHits > 10) {
this.searchOffset += 10
if (this.searchOffset + 10 > this.numHits) { this.searchOffset = this.numHits - 10}
this.searchResults = await this.search()
document.documentElement.scrollTop = 0
}
},
/** Get previous page of search results */
async prevResultsPage () {
this.searchOffset -= 10
if (this.searchOffset < 0) { this.searchOffset = 0 }
this.searchResults = await this.search()
document.documentElement.scrollTop = 0
}
}
})
這個應用程序非常簡單 —— 我們只定義了一些共享的數據屬性,以及添加了檢索和分頁搜索結果的方法。為防止每次按鍵一次都調用 API,搜索輸入有一個 100 毫秒的除顫功能。
解釋 Vue.js 是如何工作的已經超出了本教程的範圍,如果你使用過 Angular 或者 React,其實一些也不可怕。如果你完全不熟悉 Vue,想快速了解它的功能,我建議你從官方的快速指南入手 —— https://vuejs.org/v2/guide/
7.1 - HTML
使用以下的內容替換 /public/index.html
文件中的佔位符,以便於載入我們的 Vue.js 應用程序和設計一個基本的搜索界面。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Elastic Library</title>
<meta name="description" content="Literary Classic Search Engine.">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="https://cdnjs.cloudflare.com/ajax/libs/normalize/7.0.0/normalize.min.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.muicss.com/mui-0.9.20/css/mui.min.css" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=EB+Garamond:400,700|Open+Sans" rel="stylesheet">
<link href="styles.css" rel="stylesheet" />
</head>
<body>
<div class="app-container" id="vue-instance">
<!-- Search Bar Header -->
<div class="mui-panel">
<div class="mui-textfield">
<input v-model="searchTerm" type="text" v-on:keyup="onSearchInput()">
<label>Search</label>
</div>
</div>
<!-- Search Metadata Card -->
<div class="mui-panel">
<div class="mui--text-headline">{{ numHits }} Hits</div>
<div class="mui--text-subhead">Displaying Results {{ searchOffset }} - {{ searchOffset + 9 }}</div>
</div>
<!-- Top Pagination Card -->
<div class="mui-panel pagination-panel">
<button class="mui-btn mui-btn--flat" v-on:click="prevResultsPage()">Prev Page</button>
<button class="mui-btn mui-btn--flat" v-on:click="nextResultsPage()">Next Page</button>
</div>
<!-- Search Results Card List -->
<div class="search-results" ref="searchResults">
<div class="mui-panel" v-for="hit in searchResults" v-on:click="showBookModal(hit)">
<div class="mui--text-title" v-html="hit.highlight.text[0]"></div>
<div class="mui-divider"></div>
<div class="mui--text-subhead">{{ hit._source.title }} - {{ hit._source.author }}</div>
<div class="mui--text-body2">Location {{ hit._source.location }}</div>
</div>
</div>
<!-- Bottom Pagination Card -->
<div class="mui-panel pagination-panel">
<button class="mui-btn mui-btn--flat" v-on:click="prevResultsPage()">Prev Page</button>
<button class="mui-btn mui-btn--flat" v-on:click="nextResultsPage()">Next Page</button>
</div>
<!-- INSERT BOOK MODAL HERE -->
</div>
<script src="https://cdn.muicss.com/mui-0.9.28/js/mui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.3/vue.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.17.0/axios.min.js"></script>
<script src="app.js"></script>
</body>
</html>
7.2 - CSS
添加一個新文件 /public/styles.css
,使用一些自定義的 UI 樣式。
body { font-family: 'EB Garamond', serif; }
.mui-textfield > input, .mui-btn, .mui--text-subhead, .mui-panel > .mui--text-headline {
font-family: 'Open Sans', sans-serif;
}
.all-caps { text-transform: uppercase; }
.app-container { padding: 16px; }
.search-results em { font-weight: bold; }
.book-modal > button { width: 100%; }
.search-results .mui-divider { margin: 14px 0; }
.search-results {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
.search-results > div {
flex-basis: 45%;
box-sizing: border-box;
cursor: pointer;
}
@media (max-width: 600px) {
.search-results > div { flex-basis: 100%; }
}
.paragraphs-container {
max-width: 800px;
margin: 0 auto;
margin-bottom: 48px;
}
.paragraphs-container .mui--text-body1, .paragraphs-container .mui--text-body2 {
font-size: 1.8rem;
line-height: 35px;
}
.book-modal {
width: 100%;
height: 100%;
padding: 40px 10%;
box-sizing: border-box;
margin: 0 auto;
background-color: white;
overflow-y: scroll;
position: fixed;
top: 0;
left: 0;
}
.pagination-panel {
display: flex;
justify-content: space-between;
}
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
@media (max-width: 600px) {
.title-row{
flex-direction: column;
text-align: center;
align-items: center
}
}
.locations-label {
text-align: center;
margin: 8px;
}
.modal-footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: space-around;
background: white;
}
7.3 - 嘗試輸出
在你的瀏覽器中打開 localhost:8080
,你將看到一個簡單的帶結果分頁功能的搜索界面。在頂部的搜索框中嘗試輸入不同的關鍵字來查看它們的搜索情況。
你沒有必要重新運行
docker-compose up
命令以使更改生效。本地的public
目錄是裝載在我們的 Nginx 文件伺服器容器中,因此,在本地系統中前端的變化將在容器化應用程序中自動反映出來。
如果你嘗試點擊任何搜索結果,什麼反應也沒有 —— 因為我們還沒有為這個應用程序添加進一步的相關功能。
8 - 分頁預覽
如果能點擊每個搜索結果,然後查看到來自書中的內容,那將是非常棒的體驗。
8.0 - 添加 Elasticsearch 查詢
首先,我們需要定義一個簡單的查詢去從給定的書中獲取段落範圍。
在 server/search.js
文件中添加如下的函數到 module.exports
塊中。
/** Get the specified range of paragraphs from a book */
getParagraphs (bookTitle, startLocation, endLocation) {
const filter = [
{ term: { title: bookTitle } },
{ range: { location: { gte: startLocation, lte: endLocation } } }
]
const body = {
size: endLocation - startLocation,
sort: { location: 'asc' },
query: { bool: { filter } }
}
return client.search({ index, type, body })
}
這個新函數將返回給定的書的開始位置和結束位置之間的一個排序後的段落數組。
8.1 - 添加 API 端點
現在,我們將這個函數鏈接到 API 端點。
添加下列內容到 server/app.js
文件中最初的 /search
端點下面。
/**
* GET /paragraphs
* Get a range of paragraphs from the specified book
* Query Params -
* bookTitle: string under 256 characters
* start: positive integer
* end: positive integer greater than start
*/
router.get('/paragraphs',
validate({
query: {
bookTitle: joi.string().max(256).required(),
start: joi.number().integer().min(0).default(0),
end: joi.number().integer().greater(joi.ref('start')).default(10)
}
}),
async (ctx, next) => {
const { bookTitle, start, end } = ctx.request.query
ctx.body = await search.getParagraphs(bookTitle, start, end)
}
)
8.2 - 添加 UI 功能
現在,我們的新端點已經就緒,我們為應用程序添加一些從書中查詢和顯示全部頁面的前端功能。
在 /public/app.js
文件的 methods
塊中添加如下的函數。
/** Call the API to get current page of paragraphs */
async getParagraphs (bookTitle, offset) {
try {
this.bookOffset = offset
const start = this.bookOffset
const end = this.bookOffset + 10
const response = await axios.get(`${this.baseUrl}/paragraphs`, { params: { bookTitle, start, end } })
return response.data.hits.hits
} catch (err) {
console.error(err)
}
},
/** Get next page (next 10 paragraphs) of selected book */
async nextBookPage () {
this.$refs.bookModal.scrollTop = 0
this.paragraphs = await this.getParagraphs(this.selectedParagraph._source.title, this.bookOffset + 10)
},
/** Get previous page (previous 10 paragraphs) of selected book */
async prevBookPage () {
this.$refs.bookModal.scrollTop = 0
this.paragraphs = await this.getParagraphs(this.selectedParagraph._source.title, this.bookOffset - 10)
},
/** Display paragraphs from selected book in modal window */
async showBookModal (searchHit) {
try {
document.body.style.overflow = 'hidden'
this.selectedParagraph = searchHit
this.paragraphs = await this.getParagraphs(searchHit._source.title, searchHit._source.location - 5)
} catch (err) {
console.error(err)
}
},
/** Close the book detail modal */
closeBookModal () {
document.body.style.overflow = 'auto'
this.selectedParagraph = null
}
這五個函數提供了通過頁碼從書中下載和分頁(每次十個段落)的邏輯。
現在,我們需要添加一個 UI 去顯示書的頁面。在 /public/index.html
的 <!-- INSERT BOOK MODAL HERE -->
注釋下面添加如下的內容。
<!-- Book Paragraphs Modal Window -->
<div v-if="selectedParagraph" ref="bookModal" class="book-modal">
<div class="paragraphs-container">
<!-- Book Section Metadata -->
<div class="title-row">
<div class="mui--text-display2 all-caps">{{ selectedParagraph._source.title }}</div>
<div class="mui--text-display1">{{ selectedParagraph._source.author }}</div>
</div>
<br>
<div class="mui-divider"></div>
<div class="mui--text-subhead locations-label">Locations {{ bookOffset - 5 }} to {{ bookOffset + 5 }}</div>
<div class="mui-divider"></div>
<br>
<!-- Book Paragraphs -->
<div v-for="paragraph in paragraphs">
<div v-if="paragraph._source.location === selectedParagraph._source.location" class="mui--text-body2">
<strong>{{ paragraph._source.text }}</strong>
</div>
<div v-else class="mui--text-body1">
{{ paragraph._source.text }}
</div>
<br>
</div>
</div>
<!-- Book Pagination Footer -->
<div class="modal-footer">
<button class="mui-btn mui-btn--flat" v-on:click="prevBookPage()">Prev Page</button>
<button class="mui-btn mui-btn--flat" v-on:click="closeBookModal()">Close</button>
<button class="mui-btn mui-btn--flat" v-on:click="nextBookPage()">Next Page</button>
</div>
</div>
再次重啟應用程序伺服器(docker-compose up -d --build
),然後打開 localhost:8080
。當你再次點擊搜索結果時,你將能看到關鍵字附近的段落。如果你感興趣,你現在甚至可以看這本書的剩餘部分。
祝賀你!你現在已經完成了本教程的應用程序。
你可以去比較你的本地結果與託管在這裡的完整示例 —— https://search.patricktriest.com/。
9 - Elasticsearch 的缺點
9.0 - 耗費資源
Elasticsearch 是計算密集型的。官方建議 運行 ES 的機器最好有 64 GB 的內存,強烈反對在低於 8 GB 內存的機器上運行它。Elasticsearch 是一個 內存中 資料庫,這樣使它的查詢速度非常快,但這也非常佔用系統內存。在生產系統中使用時,他們強烈建議在一個集群中運行多個 Elasticsearch 節點,以實現高可用、自動分區和一個節點失敗時的數據冗餘。
我們的這個教程中的應用程序運行在一個 $15/月 的 GCP 計算實例中( search.patricktriest.com),它只有 1.7 GB 的內存,它勉強能運行這個 Elasticsearch 節點;有時候在進行初始的數據載入過程中,整個機器就 」假死機「 了。在我的經驗中,Elasticsearch 比傳統的那些資料庫,比如,PostgreSQL 和 MongoDB 耗費的資源要多很多,這樣會使託管主機的成本增加很多。
9.1 - 與資料庫的同步
對於大多數應用程序,將數據全部保存在 Elasticsearch 並不是個好的選擇。可以使用 ES 作為應用程序的主要事務資料庫,但是一般不推薦這樣做,因為在 Elasticsearch 中缺少 ACID,如果大量讀取數據的時候,它能導致寫操作丟失。在許多案例中,ES 伺服器更多是一個特定的角色,比如做應用程序中的一個文本搜索功能。這種特定的用途,要求它從主資料庫中複製數據到 Elasticsearch 實例中。
比如,假設我們將用戶信息保存在一個 PostgreSQL 表中,但是用 Elasticsearch 去提供我們的用戶搜索功能。如果一個用戶,比如,「Albert」,決定將他的名字改成 「Al」,我們將需要把這個變化同時反映到我們主要的 PostgreSQL 資料庫和輔助的 Elasticsearch 集群中。
正確地集成它們可能比較棘手,最好的答案將取決於你現有的應用程序棧。這有多種開源方案可選,從 用一個進程去關注 MongoDB 操作日誌 並自動同步檢測到的變化到 ES,到使用一個 PostgresSQL 插件 去創建一個定製的、基於 PSQL 的索引來與 Elasticsearch 進行自動溝通。
如果沒有有效的預構建選項可用,你可能需要在你的伺服器代碼中增加一些鉤子,這樣可以基於資料庫的變化來手動更新 Elasticsearch 索引。最後一招,我認為是一個最後的選擇,因為,使用定製的業務邏輯去保持 ES 的同步可能很複雜,這將會給應用程序引入很多的 bug。
讓 Elasticsearch 與一個主資料庫同步,將使它的架構更加複雜,其複雜性已經超越了 ES 的相關缺點,但是當在你的應用程序中考慮添加一個專用的搜索引擎的利弊得失時,這個問題是值的好好考慮的。
總結
在很多現在流行的應用程序中,全文搜索是一個非常重要的功能 —— 而且是很難實現的一個功能。對於在你的應用程序中添加一個快速而又可定製的文本搜索,Elasticsearch 是一個非常好的選擇,但是,在這裡也有一個替代者。Apache Solr 是一個類似的開源搜索平台,它是基於 Apache Lucene 構建的,與 Elasticsearch 的核心庫是相同的。Algolia 是一個搜索即服務的 Web 平台,它已經很快流行了起來,並且它對新手非常友好,很易於上手(但是作為折衷,它的可定製性較小,並且使用成本較高)。
「搜索」 特性並不是 Elasticsearch 唯一功能。ES 也是日誌存儲和分析的常用工具,在一個 ELK(Elasticsearch、Logstash、Kibana)架構配置中通常會使用它。靈活的全文搜索功能使得 Elasticsearch 在數據量非常大的科學任務中用處很大 —— 比如,在一個數據集中正確的/標準化的條目拼寫,或者為了類似的片語搜索一個文本數據集。
對於你自己的項目,這裡有一些創意。
- 添加更多你喜歡的書到教程的應用程序中,然後創建你自己的私人圖書館搜索引擎。
- 利用來自 Google Scholar 的論文索引,創建一個學術抄襲檢測引擎。
- 通過將字典中的每個詞索引到 Elasticsearch,創建一個拼寫檢查應用程序。
- 通過將 Common Crawl Corpus 載入到 Elasticsearch 中,構建你自己的與谷歌競爭的網際網路搜索引擎(注意,它可能會超過 50 億個頁面,這是一個成本極高的數據集)。
- 在 journalism 上使用 Elasticsearch:在最近的大規模泄露的文檔中搜索特定的名字和關鍵詞,比如, Panama Papers 和 Paradise Papers。
本教程中應用程序的源代碼是 100% 公開的,你可以在 GitHub 倉庫上找到它們 —— https://github.com/triestpa/guttenberg-search
我希望你喜歡這個教程!你可以在下面的評論區,發表任何你的想法、問題、或者評論。
作者簡介:
全棧工程師,數據愛好者,學霸,「構建強迫症患者」,探險愛好者。
via: https://blog.patricktriest.com/text-search-docker-elasticsearch/
作者:Patrick Triest 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive