使用Docker和Elasticsearch搭建全文本搜索引擎應(yīng)用

給應(yīng)用添加快速溜嗜、靈活的全文本搜索對誰都不是一件容易的事情火俄。許多主流數(shù)據(jù)庫工三,如PostgreSQL和MongoDB迁酸,受限于查詢和索引結(jié)構(gòu),只提供基礎(chǔ)文本搜索能力俭正。為了提供高效全文本搜索一般都需要一個獨立的數(shù)據(jù)庫奸鬓。Elasticsearch正是這樣一個能夠提供靈活性和快速全文本搜索能力的開源數(shù)據(jù)庫。

本文采用Docker來設(shè)置依賴環(huán)境掸读。Docker是目前最常見的容器化引擎串远,Uber、Spotify儿惫、ADP和Paypal都是用這個技術(shù)澡罚,它的優(yōu)勢在于與操作系統(tǒng)無關(guān),可以運行在Windows姥闪、macOS和Linux之上——寫操作指南很容易始苇。如果從來沒有用過Docker也沒問題,本文會詳細提供配置文件筐喳。

本文也分別采用Node.js采(用Koa框架)和Vue.js創(chuàng)建搜索API和前端Web應(yīng)用催式。

1. 什么是Elasticsearch

現(xiàn)代應(yīng)用中全文本檢索是高請求負載的應(yīng)用。搜索功能也是比較困難完成的功能(許多大眾網(wǎng)站都有subpar功能避归,但不是返回很慢就是返回結(jié)果不準確)荣月,大部分原因是因為底層數(shù)據(jù)庫:許多標(biāo)準關(guān)系型數(shù)據(jù)庫只能提供基本字符串匹配功能,而對CONTAINS或者LIKE SQL查詢只能提供有限支持梳毙。

而本文提供的搜索應(yīng)用能夠提供:

  1. 快速:查詢結(jié)果應(yīng)該實時返回哺窄,提高用戶體驗。
  2. 靈活:根據(jù)不同數(shù)據(jù)和使用場景,可以調(diào)整搜索過程?萌业。
  3. 最佳建議:對于輸入錯誤坷襟,返回最可能的結(jié)果。
  4. 全文本:除了搜索關(guān)鍵詞和標(biāo)簽之外生年,希望能夠搜索到所有匹配文本?婴程。

實現(xiàn)以上要求的搜索應(yīng)用,最好采用一個為全文本檢索優(yōu)化的數(shù)據(jù)庫抱婉,這也是本文采用Elasticsearch的原因档叔。Elasticsearch是一個用Java開發(fā)的,開源的內(nèi)存數(shù)據(jù)庫蒸绩,最開始是包含在Apache Lucene庫中衙四。以下是一些官方給出的Elasticsearch使用場景:

  1. Wikipedia使用Elasticsearch提供全文檢索,提供高亮顯示患亿、search-as-you-type和did-you-mean建議等功能传蹈。
  2. Guardian使用Elasticsearch將訪問者社交數(shù)據(jù)?整合反饋給作者。
  3. Stack Overflow將位置信息和more-like-this功能與全文本檢索整合提供相關(guān)問題和答案步藕。
  4. GitHub使用Elasticsearch在一千三百億行代碼中進行搜索卡睦。?

Elasticsearch有什么獨特之處

本質(zhì)上,Elasticsearch通過使用反向索引提供快速和靈活的全文本搜索漱抓。

“索引”是一種在數(shù)據(jù)庫中提供快速查詢和返回的數(shù)據(jù)結(jié)構(gòu)。數(shù)據(jù)庫一般將數(shù)據(jù)域和相應(yīng)表位置生成索引信息恕齐。將索引信息存放在一個可搜索的數(shù)據(jù)結(jié)構(gòu)中(一般是B-Tree)乞娄,數(shù)據(jù)庫可以為優(yōu)化數(shù)據(jù)請求獲得線性搜索響應(yīng)(例如“Find the row with ID=5”)。

可以把數(shù)據(jù)庫索引看做學(xué)校圖書館卡片分類系統(tǒng)显歧,只要知道書名和作者仪或,就可以準確告訴查找內(nèi)容的入口。數(shù)據(jù)庫表一般都有多個索引表士骤,可以加速查詢(例如范删,對name列的索引可以極大加速對特定name的查詢)。

而反向索引工作原理與此完全不同拷肌。每行(或者每個文檔)的內(nèi)容被分拆?到旦,每個入口(本案例中是每個單詞)反向指向包含它的文檔。

反向索引數(shù)據(jù)結(jié)構(gòu)對查詢“football”位于哪個文檔這種查詢非常迅速巨缘。Elasticsearch使用內(nèi)存優(yōu)化反向索引添忘,可以實現(xiàn)強大和客制化全文本檢索任務(wù)。

2. 項目安裝

2.0 Docker

本文使用Docker作為項目開發(fā)環(huán)境若锁。Docker是一個容器化引擎搁骑,應(yīng)用可以運行在隔離環(huán)境中,不依賴于本地操作系統(tǒng)和開發(fā)環(huán)境。因為可以帶來巨大靈活性和客制化仲器,許多互聯(lián)網(wǎng)公司應(yīng)用都已經(jīng)運行在容器中煤率。

對于作者來說,Docker可以提供平臺一致性安裝環(huán)境(可以運行在Windows乏冀、macOS和Linux系統(tǒng))蝶糯。一般Node.js、Elasticsearch和Nginx都需要不同安裝步驟煤辨,如果運行在Docker環(huán)境中只需要定義好不同配置文件裳涛,就可以運行在任何Docker環(huán)境。另外众辨,由于應(yīng)用各自運行在隔離容器中端三,與本地宿主機關(guān)系很小,因此類似于“但是我這可以運行啊”這種排錯問題就很少會出現(xiàn)鹃彻。

2.1 安裝Docker和Docker-Compose

本項目只需要Docker和Docker-Compose環(huán)境郊闯。后者是Docker官方工具,在單一應(yīng)用棧中編排定義多個容器配置蛛株。

安裝Docker——https://docs.docker.com/engine/installation/
安裝Docker Compose——https://docs.docker.com/compose/install/

2.2 設(shè)置項目安裝目錄

創(chuàng)建一個項目根目錄(例如guttenberg_search)团赁,在其下定義兩個子目錄:

  • /public——為前端 Vue.js webapp存放數(shù)據(jù)。
  • /server——服務(wù)器端Node.js 源文件谨履。

2.3 添加Docker-Compose配置文件

下一步欢摄,創(chuàng)建docker-compose.yml文件,定義應(yīng)用棧中每個容器的配置:

  1. gs-api——Node.js 容器后端應(yīng)用邏輯.
  2. gs-frontend——為前端webapp提供服務(wù)的Nginx容器
  3. gs-search——存儲搜索數(shù)據(jù)的Elasticsearch容器

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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:
</pre>

此文件定義應(yīng)用棧笋粟,而不需要在本地宿主機安裝Elasticsearch怀挠、Node.js、或者Nginx害捕。每個容器都對宿主機開放相應(yīng)端口绿淋,以便從宿主機訪問和排錯Node API,Elasticsearch實例和前端應(yīng)用尝盼。

2.4 添加Dockerfile

本文使用官方的Nginx和Elasticsearch鏡像吞滞,但是需要重新為Node.js創(chuàng)建自己的鏡像。

在應(yīng)用根目錄定義一個簡單的Dockerfile配置文件盾沫。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);"># 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" ]
</pre>

此Docker配置文件中將應(yīng)用源碼拷貝進來裁赠,安裝了NPM依賴包,形成了自己的鏡像赴精。同樣需要添加一個.dockerignore文件组贺,避免不需要的文件被拷入。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">node_modules/
npm-debug.log
books/
public/
</pre>

注意:不需要將node_modules拷入祖娘,因為我們后續(xù)要用npm install來安裝這些進程失尖。如果拷貝node_modules到容器中容易引起兼容性問題啊奄。例如在macOS上安裝bcrypt包,如果將此module拷入Ubuntu容器就會引起操作系統(tǒng)不匹配問題掀潮。

2.5 添加基礎(chǔ)文件

測試配置文件前菇夸,還需要往應(yīng)用目錄拷入一下占位文件。在public/index.html中加入如下基礎(chǔ)配置信息:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);"><html><body>Hello World From The Frontend Container</body></html>
</pre>

下一步仪吧,在server/app.js中加入Node.js的應(yīng)用文件庄新。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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}
})
</pre>

最后,加入package.json節(jié)點配置文件:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">{
"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"
}
}
</pre>

此文件定義應(yīng)用開始命令和Node.js依賴包薯鼠。

注意:不需要特意運行npm install择诈,容器創(chuàng)建時候會自動安裝依賴包。

2.6 開始測試

都準備好了出皇,接下來可以測試了羞芍。從項目根目錄開始,運行docker-compose郊艘,會自動創(chuàng)建Node.js容器應(yīng)用荷科。

運行docker-compose up啟動應(yīng)用:

注意:這一步可能會運行時間比較長,因為Docker可能需要下載基礎(chǔ)鏡像纱注。以后執(zhí)行速度會很快畏浆,因為本地已經(jīng)有了基礎(chǔ)鏡像。

訪問localhost:8080狞贱,應(yīng)該看到如下圖輸出“hello world”刻获。

訪問localhost:3000驗證服務(wù)器端返回“hello world”信息。

最后瞎嬉,訪問localhost:9200確認Elasticsearch是否運行将鸵,如果正常,應(yīng)該返回如下輸出:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">{
"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"
}
</pre>

如果所有URL輸出都正常佑颇,恭喜,整個應(yīng)用框架可以正常工作草娜,下面開始進入真正有趣的部分了挑胸。

3. 接入Elasticsearch

第一步是要接入本地Elasticsearch實例。

3.0 加入ES鏈接模塊

在server/connection.js中加入如下初始化代碼:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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()
</pre>

下面用docker-compose來重建更改過的應(yīng)用宰闰。之后運行docker-compose up -d重新啟動后臺進程茬贵。

應(yīng)用啟動后,命令行運行docker exec gs-api "node" "server/connection.js"移袍,在容器中運行腳本解藻,應(yīng)該可以看到如下輸出:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">{ 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 }
</pre>

如果一切順利,就可以把最后一行的checkConnection()調(diào)用刪掉葡盗,因為最終應(yīng)用會從connection模塊之外調(diào)用它螟左。

3.1 給Reset Index添加Helper功能

在server/connection.js文件checkConnection之下添加如下內(nèi)容, 以便更加方便重置索引。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** Clear the index, recreate it, and add mappings */
async function resetIndex () {
if (await client.indices.exists({ index })) {
await client.indices.delete({ index })
}

await client.indices.create({ index })
await putBookMapping()
}
</pre>

3.2 添加Book Schema

緊接resetIndex之后,添加如下功能:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** 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 } })
}
</pre>

此處為書目索引定義了mapping(映射)胶背。Elasticsearch索引類似于SQL的表或者MongoDB的connection巷嚣。通過mapping我們可以定義文檔每個域和數(shù)據(jù)類型。Elasticsearch是schema-less钳吟,因此技術(shù)上說不需要添加mapping,但是通過mapping可以更好控制數(shù)據(jù)處理方式。

例如就乓,有兩個關(guān)鍵詞域片部,分別是“titile”和“author”,文本定為“text”域暇番。這樣定義搜索引擎會有完全不同的動作:搜索中嗤放,引擎會在text域中查找可能匹配項,而在關(guān)鍵詞域則是精確匹配奔誓〗锿拢看起來差別不大,但卻對搜索行為和搜索速度有很大影響厨喂。

在文件最后輸出功能和屬性和措,可以被其它模塊訪問。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">module.exports = {
client, index, type, checkConnection, resetIndex
}
</pre>

4. 加載源數(shù)據(jù)

本文使用從Gutenberg項目(一個在線提供免費電子書的應(yīng)用)提供的數(shù)據(jù)蜕煌。包括100本經(jīng)典書目派阱,例如《80天環(huán)繞地球》、《羅密歐與朱麗葉》以及《奧德賽》等斜纪。

4.1 下載書籍?dāng)?shù)據(jù)

本文的數(shù)據(jù)可以從以下網(wǎng)站下載:
https://cdn.patricktriest.com/data/books.zip贫母,之后解壓到項目根目錄下的books/ 子目錄下。

也可以用命令行實現(xiàn)以上操作:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">wget https://cdn.patricktriest.com/data/books.zip
unar books.zip
</pre>

4.2 預(yù)覽書籍

打開一本書盒刚,例如219-0.txt腺劣。書籍以公開訪問license開始,跟著是書名因块、作者橘原、發(fā)行日期、語言以及字符編碼涡上。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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
</pre>

隨后是聲明信息:* * * START OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS * * *趾断,緊接著就是書的實際內(nèi)容。

書的最后會發(fā)現(xiàn)書籍結(jié)束聲明: * * * END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS * * *吩愧,緊跟著是更加詳細的書籍license芋酌。

下一步將用編程方法從書中提取元數(shù)據(jù),并且從* * *之間將書籍內(nèi)容抽取出來雁佳。

4.3 讀取數(shù)據(jù)目錄

本節(jié)寫一段腳本讀取書籍內(nèi)容添加到Elasticsearch中脐帝,腳本存放在server/load_data.js 中同云。

首先,獲得books目錄下所有文件列表腮恩。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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()
</pre>

運行docker-compose -d --build重建鏡像更新應(yīng)用梢杭。

運行docker exec gs-api "node" "server/load_data.js"調(diào)用包含load_data腳本應(yīng)用,應(yīng)該看到Elasticsearch輸出如下秸滴。隨后武契,腳本會因為錯誤退出,原因是調(diào)用了一本目前還不存在的helper函數(shù)(parseBookFile)荡含。

4.4 讀取數(shù)據(jù)文件

創(chuàng)建server/load_data.js文件咒唆,讀取每本書元數(shù)據(jù)和內(nèi)容:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** 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(/\n\s+\n/g) // Split each paragraph into it's own array entry
.map(line => line.replace(/\r\n/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} Paragraphs\n)
return { title, author, paragraphs }
}
</pre>

此函數(shù)執(zhí)行以下功能:

  1. 從文件系統(tǒng)中讀入文件
  2. 使用正則表達式抽取書名和作者
  3. 通過定位* * *,來抽取書中內(nèi)容
  4. 解析出段落
  5. 清洗數(shù)據(jù)释液,移除空行

最后返回一個包含書名全释、作者和段落列表的對象。

運行docker-compose up -d --build和docker exec gs-api "node" "server/load_data.js" 误债,輸出如下:

到這步浸船,腳本順利分理出書名和作者,腳本還會因為同樣問題出錯(調(diào)用還未定義的函數(shù))寝蹈。

4.5 在ES中索引數(shù)據(jù)文件

最后一步在load_data.js中添加insertBookData函數(shù)李命,將上一節(jié)中提取數(shù)據(jù)插入Elasticsearch索引中。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** 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}\n\n\n)
}
</pre>

此函數(shù)索引書籍段落箫老,包括作者封字、書名和段落元數(shù)據(jù)信息。使用bulk操作插入段落耍鬓,比分別索引段落效率高很多阔籽。

批量bulk索引這些段落可以使本應(yīng)用運行在低配電腦上(我只有1.7G內(nèi)存),如果你有高配電腦(大于4G內(nèi)容)牲蜀,也許不用考慮批量bulk操作笆制。

運行docker-compose up -d --build 和 docker exec gs-api "node" "server/load_data.js" 輸出如下:

5. 搜索

Elasticsearch已經(jīng)灌入100本書籍?dāng)?shù)據(jù)(大約230000段落),本節(jié)做一些搜索操作涣达。

5.0 簡單http查詢

首先在辆,使用http://localhost:9200/library/ ... retty , 這里使用全文本查詢關(guān)鍵字“Java”峭判,輸入應(yīng)該如下:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">{
"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."
}
},
...
]
}
}
</pre>

Elasticsearch HTTP接口對于測試數(shù)據(jù)是否正常插入很有用,但是如果直接暴露給web應(yīng)用就很危險棕叫。不應(yīng)該將操作性API功能(例如直接添加和刪除文檔)直接暴露給應(yīng)用林螃,而應(yīng)該寫一段簡單Node.js API接收客戶端請求,(通過私網(wǎng))轉(zhuǎn)發(fā)給Elasticsearch進行查詢俺泣。

5.1 請求腳本

這一節(jié)介紹如何從Node.js應(yīng)用中向Elasticsearch中發(fā)送請求疗认。首先創(chuàng)建新文件:server/search.js完残。

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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 })
}
}
</pre>

本模塊定義了一個簡單的search功能,使用輸入信息進行匹配查詢横漏。詳細字段解釋如下:

  1. from:為結(jié)果標(biāo)出頁碼谨设。每次查詢默認返回10個結(jié)果;因此指定from為10缎浇,可以直接顯示10-20的查詢結(jié)果扎拣。
  2. query:具體查詢關(guān)鍵詞。
  3. operator:具體查詢操作素跺;本例中采用“and”操作符二蓝,優(yōu)先顯示包含所有查詢關(guān)鍵詞的結(jié)果。
  4. fuzziness:錯誤拼寫修正級別(或者是模糊查詢級別)指厌,默認是2刊愚。數(shù)值越高,允許模糊度越高踩验;例如數(shù)值1鸥诽,會對Patricc的查詢返回Patrick結(jié)果。
  5. highlights:返回額外信息箕憾,其中包含HTML格式顯示匹配文本信息牡借。 可以調(diào)整這些參數(shù)看看具體的顯示信息,可以查看Elastic Full-Text Query DSL獲得更多信息厕九。

6. API

本節(jié)提供前端代碼訪問的HTTP API蓖捶。

6.0 API Server

修改server/app.js內(nèi)容如下:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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})
})
</pre>

這段代碼導(dǎo)入服務(wù)依賴環(huán)境,為Koa.js Node API Server設(shè)置簡單日志和錯誤處理機制扁远。

6.1 將服務(wù)端點與查詢鏈接起來

這一節(jié)為Server端添加服務(wù)端點俊鱼,以便暴露給Elasticsearch查詢服務(wù)。

在server/app.js中//ADD ENDPOINTS HERE 之后插入如下代碼:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/**

  • 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)
    }
    )
    </pre>

用docker-compose up -d --build重啟服務(wù)端畅买。在瀏覽器中并闲,調(diào)用此服務(wù)。例如:http://localhost:3000/search?term=java谷羞。

返回結(jié)果看起來應(yīng)該如下:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">{
"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."]
}
}
...
]
}
}
</pre>

6.2 輸入驗證

此時服務(wù)端還是很脆弱帝火,下面對輸入?yún)?shù)進行檢查,對無效或者缺失的輸入進行甄別湃缎,并返回錯誤犀填。

我們使用Joi和Koa-Joi-Validate庫進行這種類型的驗證:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/**

  • 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)
    }
    )
    </pre>

現(xiàn)在如果重啟服務(wù)端,并做一個缺失參數(shù)查詢(http://localhost:3000/search)嗓违,將會返回HTTP 400錯誤九巡,例如:Invalid URL Query - child "term" fails because ["term" is required]。

可以用docker-compose logs -f api 查看日志蹂季。

7. 前端應(yīng)用

/search服務(wù)端硬件可以了冕广,本節(jié)寫一段簡單前端web應(yīng)用測試API疏日。

7.0 Vue.js

本節(jié)使用Vue.js來開發(fā)前端。創(chuàng)建一個新文件/public/app.js:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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
}
}
})
</pre>

應(yīng)用特別簡單撒汉,只是定義一些共享數(shù)據(jù)屬性沟优,添加一個接收方法以及為結(jié)果分頁的功能;搜索間隔設(shè)置為100ms睬辐,以防API被頻繁調(diào)用挠阁。

解釋Vue.js如何工作超出本文的范圍,如果想了解相關(guān)內(nèi)容溉委,可以查看Vue.js官方文檔.

7.1 HTML

將/public/index.html用如下內(nèi)容代替:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);"><!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 rel="stylesheet" type="text/css" />
<link rel="stylesheet" type="text/css" />
<link rel="stylesheet">
<link href="styles.css" rel="stylesheet" />
</head>
<body>
<div class="app-container" id="vue-instance">

<div class="mui-panel">
<div class="mui-textfield">
<input v-model="searchTerm" type="text" v-on:keyup="onSearchInput()">
<label>Search</label>
</div>
</div>


<div class="mui-panel">
<div class="mui--text-headline">{{ numHits }} Hits</div>
<div class="mui--text-subhead">Displaying Results {{ searchOffset }} - {{ searchOffset + 9 }}</div>
</div>


<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>


<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>


<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>


</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>
</pre>

7.3 CSS

添加一個新文件:/public/styles.css:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">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;
}
</pre>

7.3 測試

打開localhost:8080鹃唯,應(yīng)該能夠看到一個簡單分頁返回結(jié)果。此時可以鍵入一些關(guān)鍵詞進行查詢測試瓣喊。

這一步不需要重新運行docker-compose up命令使修改生效坡慌。本地public目錄直接掛載在Ngnix服務(wù)器容器中,因此前端本地系統(tǒng)數(shù)據(jù)改變直接反應(yīng)在容器化應(yīng)用中藻三。

如果點任一個輸出洪橘,沒什么效果,意味著還有一些功能需要添加進應(yīng)用中棵帽。

8. 頁面檢查

最好點擊任何一個輸出熄求,可以查出上下文來自哪本書。

8.0 添加Elasticsearch查詢

首先逗概,需要定義一個從給定書中獲得段落的簡單查詢弟晚。在server/search.js下的module.exports中加入如下內(nèi)容:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** 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 })
}
</pre>

此功能將返回給定書排序后的段落。

8.1 添加API服務(wù)端口

本節(jié)將把上節(jié)功能鏈接到API服務(wù)端口逾苫。在server/app.js中原來的/search服務(wù)端口下添加如下內(nèi)容:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/**

  • 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)
    }
    )
    </pre>

8.2 添加UI界面

本節(jié)添加前端查詢功能卿城,并顯示書中包含查詢內(nèi)容的整頁信息。在/public/app.js methods功能塊中添加如下內(nèi)容:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">/** 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
}
</pre>

以上五個功能塊提供在書中下載和分頁(每頁顯示10段)邏輯操作铅搓。

在/public/index.html 中的<!- - INSERT BOOK MODAL HERE -->分界符下加入顯示書頁的UI代碼如下:

<pre class="prettyprint" style="box-sizing: border-box; overflow: hidden; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 14px; display: block; padding: 16px; margin: 0px 0px 10px; line-height: 20px; color: rgb(51, 51, 51); word-break: break-all; word-wrap: break-word; border: none; border-radius: 3px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(247, 247, 247);">
<div v-if="selectedParagraph" ref="bookModal" class="book-modal">
<div class="paragraphs-container">

<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>


<div class="mui-divider"></div>
<div class="mui--text-subhead locations-label">Locations {{ bookOffset - 5 }} to {{ bookOffset + 5 }}</div>
<div class="mui-divider"></div>

<!-- 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>


<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>
</pre>

重啟應(yīng)用服務(wù)器(docker-compose up -d --build)瑟押,打開localhost:8080。此時如果點擊搜索結(jié)果星掰,就可以查詢段落上下文多望。如果對查到結(jié)果感興趣,甚至可以從查詢處一直讀下去氢烘。

恭喜;惩怠!到這一步主體框架已經(jīng)搭建完畢播玖。以上所有代碼都可以從這里獲得椎工。

9. Elasticsearch的不足

9.0 資源消耗

Elasticsearch是計算資源消耗的應(yīng)用。官方建議至少運行在64G以上內(nèi)存的設(shè)備上,不建議少于8GB內(nèi)存晋渺。Elasticsearch是一個內(nèi)存數(shù)據(jù)庫,因此查詢速度會很快脓斩,但是也會消耗大量內(nèi)存木西。生產(chǎn)中,強烈推薦運行Elasticsearch集群提供高可用性随静、自動分片和數(shù)據(jù)冗余功能八千。

我在一個1.7GB的云設(shè)備上(每月15美金)運行以上示例(search.patriktriest.com),這些資源僅是能夠運行Elasticsearch節(jié)點燎猛。有時整個節(jié)點會在初始裝載數(shù)據(jù)時候hang住恋捆。從我的經(jīng)驗看,Elasticsearch比傳統(tǒng)的PostgreSQL和MongoDB跟消耗資源重绷,如果需要提供理想服務(wù)效果沸停,成本可能會很貴。

9.1 數(shù)據(jù)庫之間的同步

對許多應(yīng)用昭卓,將數(shù)據(jù)存放在Elasticsearch中并不是理想的選擇愤钾。建議將ES作為交易型數(shù)據(jù)庫,但是因為ES不兼容ACID標(biāo)準(當(dāng)擴展系統(tǒng)導(dǎo)入數(shù)據(jù)時候醒,可能造成寫入操作丟失的問題)能颁,所以也不推薦。很多場景下倒淫,ES承擔(dān)著很特殊的角色伙菊,例如全文本查詢,這種場景下需要某些數(shù)據(jù)從主數(shù)據(jù)庫復(fù)制到Elasticsearch數(shù)據(jù)庫中敌土。

例如镜硕,假設(shè)我們需要將用戶存放到PostgreSQL表中,但是使用ES承擔(dān)用戶查詢功能纯赎。如果一個用戶谦疾,“Albert”,決定修改名字為“Al”犬金,就需要在主PostgreSQL庫和ES集群中同時進行修改念恍。

這個操作有些復(fù)雜,依賴現(xiàn)有的軟件棧晚顷。有許多開源資源可選峰伙,既有監(jiān)控MongoDB操作日志并自動同步刪除數(shù)據(jù)到ES的進程,到創(chuàng)建客制化基于PSQL索引自動與ES通訊的PostgreSQL插件该默。

如果之前提到的選項都無效瞳氓,可以在服務(wù)端代碼中根據(jù)數(shù)據(jù)庫變化手動更新Elasticsearch索引。但是我認為這種選擇并不是最佳的栓袖,因為使用客制化商業(yè)邏輯保持ES同步很復(fù)雜匣摘,而且有可能會引入很多bugs店诗。

Elasticsearch與主數(shù)據(jù)庫同步需求,與其說是ES的弱點音榜,不如說是架構(gòu)復(fù)雜造成的庞瘸;給應(yīng)用添加一個專用搜索引擎是一件值得考慮的事情,但是要折衷考慮帶來的問題赠叼。

結(jié)論

全文本搜索對現(xiàn)代應(yīng)用來說是一個很重要的功能擦囊,同時也是很難完成的功能。Elasticsearch則提供了實現(xiàn)快速和客制化搜索的實現(xiàn)方式嘴办,但是也有其它替代選項瞬场。Apache Solr是另外一個基于Apache Lucene(Elasticsearch核心也采用同樣的庫)實現(xiàn)的開源類似實現(xiàn)。Algolia則是最近很活躍的search-as-a-service模式web平臺涧郊,對初學(xué)者來說更加容易上手(缺點是客制化不強贯被,而且后期投入可能很大)。

“search-bar”模式功能遠不僅是Elasticsearch的唯一使用場景妆艘。ES也是一個日志存儲和分析常用工具刃榨,一般用于ELK架構(gòu)(Elasticsearch,Logstash双仍,Kibana)枢希。ES實現(xiàn)的靈活全文本搜索對數(shù)據(jù)科學(xué)家任務(wù)也很有用,例如修改朱沃、規(guī)范化數(shù)據(jù)集拼寫或者搜索數(shù)據(jù)集苞轿。

如下是有關(guān)本項目的考慮:

  1. 在應(yīng)用中添加更多喜愛的書,創(chuàng)建自己私有庫搜索引擎逗物。
  2. 通過索引Google Scholar論文搬卒,創(chuàng)建一個防抄襲引擎。
  3. 通過索引字典中單詞到ES中翎卓,建立拼寫檢查應(yīng)用契邀。
  4. 通過加載Common Crawl Corpus到ES(注意,有50億頁內(nèi)容失暴,是一個非常巨大數(shù)據(jù)集)坯门,建立自己的與谷歌競爭的互聯(lián)網(wǎng)搜索引擎。
  5. 在新聞業(yè)中使用Elasticsearch:在例如Panama論文和Paradise論文集中搜索特點名稱和詞條逗扒。

本文所有代碼都是開源的古戴,可以在GitHub庫中找到,具體下載地址矩肩。希望本文對大家有所幫助现恼。

作者簡介:Patrick Triest是一位全棧工程師,數(shù)據(jù)愛好者,持續(xù)學(xué)習(xí)者叉袍,潔癖編程者始锚。作者github地址為https://github.com/triestpa

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末喳逛,一起剝皮案震驚了整個濱河市疼蛾,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌艺配,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件衍慎,死亡現(xiàn)場離奇詭異转唉,居然都是意外死亡,警方通過查閱死者的電腦和手機稳捆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門赠法,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人乔夯,你說我怎么就攤上這事砖织。” “怎么了末荐?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵侧纯,是天一觀的道長。 經(jīng)常有香客問我甲脏,道長眶熬,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任块请,我火速辦了婚禮娜氏,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘墩新。我一直安慰自己贸弥,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布海渊。 她就那樣靜靜地躺著绵疲,像睡著了一般。 火紅的嫁衣襯著肌膚如雪臣疑。 梳的紋絲不亂的頭發(fā)上最岗,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天,我揣著相機與錄音朝捆,去河邊找鬼般渡。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的驯用。 我是一名探鬼主播脸秽,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼蝴乔!你這毒婦竟也來了记餐?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤薇正,失蹤者是張志新(化名)和其女友劉穎片酝,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體挖腰,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡雕沿,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了猴仑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片审轮。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖辽俗,靈堂內(nèi)的尸體忽然破棺而出疾渣,到底是詐尸還是另有隱情,我是刑警寧澤崖飘,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布榴捡,位于F島的核電站,受9級特大地震影響朱浴,放射性物質(zhì)發(fā)生泄漏薄疚。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一赊琳、第九天 我趴在偏房一處隱蔽的房頂上張望街夭。 院中可真熱鬧,春花似錦躏筏、人聲如沸板丽。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽埃碱。三九已至,卻和暖如春酥泞,著一層夾襖步出監(jiān)牢的瞬間砚殿,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工芝囤, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留似炎,地道東北人辛萍。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像羡藐,于是被迫代替她去往敵國和親贩毕。 傳聞我的和親對象是個殘疾皇子仆嗦,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容