美文网首页
使用Docker和Elasticsearch搭建全文本搜索引擎应

使用Docker和Elasticsearch搭建全文本搜索引擎应

作者: Solang | 来源:发表于2019-01-23 16:33 被阅读11次

    给应用添加快速、灵活的全文本搜索对谁都不是一件容易的事情。许多主流数据库,如PostgreSQL和MongoDB,受限于查询和索引结构,只提供基础文本搜索能力。为了提供高效全文本搜索一般都需要一个独立的数据库。Elasticsearch正是这样一个能够提供灵活性和快速全文本搜索能力的开源数据库。

    本文采用Docker来设置依赖环境。Docker是目前最常见的容器化引擎,Uber、Spotify、ADP和Paypal都是用这个技术,它的优势在于与操作系统无关,可以运行在Windows、macOS和Linux之上——写操作指南很容易。如果从来没有用过Docker也没问题,本文会详细提供配置文件。

    本文也分别采用Node.js采(用Koa框架)和Vue.js创建搜索API和前端Web应用。

    1. 什么是Elasticsearch

    现代应用中全文本检索是高请求负载的应用。搜索功能也是比较困难完成的功能(许多大众网站都有subpar功能,但不是返回很慢就是返回结果不准确),大部分原因是因为底层数据库:许多标准关系型数据库只能提供基本字符串匹配功能,而对CONTAINS或者LIKE SQL查询只能提供有限支持。

    而本文提供的搜索应用能够提供:

    1. 快速:查询结果应该实时返回,提高用户体验。
    2. 灵活:根据不同数据和使用场景,可以调整搜索过程​。
    3. 最佳建议:对于输入错误,返回最可能的结果。
    4. 全文本:除了搜索关键词和标签之外,希望能够搜索到所有匹配文本​。

    实现以上要求的搜索应用,最好采用一个为全文本检索优化的数据库,这也是本文采用Elasticsearch的原因。Elasticsearch是一个用Java开发的,开源的内存数据库,最开始是包含在Apache Lucene库中。以下是一些官方给出的Elasticsearch使用场景:

    1. Wikipedia使用Elasticsearch提供全文检索,提供高亮显示、search-as-you-type和did-you-mean建议等功能。
    2. Guardian使用Elasticsearch将访问者社交数据​整合反馈给作者。
    3. Stack Overflow将位置信息和more-like-this功能与全文本检索整合提供相关问题和答案。
    4. GitHub使用Elasticsearch在一千三百亿行代码中进行搜索。​

    Elasticsearch有什么独特之处

    本质上,Elasticsearch通过使用反向索引提供快速和灵活的全文本搜索。

    “索引”是一种在数据库中提供快速查询和返回的数据结构。数据库一般将数据域和相应表位置生成索引信息。将索引信息存放在一个可搜索的数据结构中(一般是B-Tree),数据库可以为优化数据请求获得线性搜索响应(例如“Find the row with ID=5”)。

    1.png

    可以把数据库索引看做学校图书馆卡片分类系统,只要知道书名和作者,就可以准确告诉查找内容的入口。数据库表一般都有多个索引表,可以加速查询(例如,对name列的索引可以极大加速对特定name的查询)。

    而反向索引工作原理与此完全不同。每行(或者每个文档)的内容被分拆​,每个入口(本案例中是每个单词)反向指向包含它的文档。

    2.jpg

    反向索引数据结构对查询“football”位于哪个文档这种查询非常迅速。Elasticsearch使用内存优化反向索引,可以实现强大和客制化全文本检索任务。

    2. 项目安装

    2.0 Docker

    本文使用Docker作为项目开发环境。Docker是一个容器化引擎,应用可以运行在隔离环境中,不依赖于本地操作系统和开发环境。因为可以带来巨大灵活性和客制化,许多互联网公司应用都已经运行在容器中。

    对于作者来说,Docker可以提供平台一致性安装环境(可以运行在Windows、macOS和Linux系统)。一般Node.js、Elasticsearch和Nginx都需要不同安装步骤,如果运行在Docker环境中只需要定义好不同配置文件,就可以运行在任何Docker环境。另外,由于应用各自运行在隔离容器中,与本地宿主机关系很小,因此类似于“但是我这可以运行啊”这种排错问题就很少会出现。

    2.1 安装Docker和Docker-Compose

    本项目只需要Docker和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 webapp存放数据。
    • /server——服务器端Node.js 源文件。

    2.3 添加Docker-Compose配置文件

    下一步,创建docker-compose.yml文件,定义应用栈中每个容器的配置:

    1. gs-api——Node.js 容器后端应用逻辑.
    2. gs-frontend——为前端webapp提供服务的Nginx容器
    3. gs-search——存储搜索数据的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>

    此文件定义应用栈,而不需要在本地宿主机安装Elasticsearch、Node.js、或者Nginx。每个容器都对宿主机开放相应端口,以便从宿主机访问和排错Node API,Elasticsearch实例和前端应用。

    2.4 添加Dockerfile

    本文使用官方的Nginx和Elasticsearch镜像,但是需要重新为Node.js创建自己的镜像。

    在应用根目录定义一个简单的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配置文件中将应用源码拷贝进来,安装了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拷入,因为我们后续要用npm install来安装这些进程。如果拷贝node_modules到容器中容易引起兼容性问题。例如在macOS上安装bcrypt包,如果将此module拷入Ubuntu容器就会引起操作系统不匹配问题。

    2.5 添加基础文件

    测试配置文件前,还需要往应用目录拷入一下占位文件。在public/index.html中加入如下基础配置信息:

    <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的应用文件。

    <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节点配置文件:

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

    此文件定义应用开始命令和Node.js依赖包。

    注意:不需要特意运行npm install,容器创建时候会自动安装依赖包。

    2.6 开始测试

    都准备好了,接下来可以测试了。从项目根目录开始,运行docker-compose,会自动创建Node.js容器应用。

    3.png

    运行docker-compose up启动应用:

    4.png

    注意:这一步可能会运行时间比较长,因为Docker可能需要下载基础镜像。以后执行速度会很快,因为本地已经有了基础镜像。

    访问localhost:8080,应该看到如下图输出“hello world”。

    5.png

    访问localhost:3000验证服务器端返回“hello world”信息。

    6.png

    最后,访问localhost:9200确认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);">{
    "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输出都正常,恭喜,整个应用框架可以正常工作,下面开始进入真正有趣的部分了。

    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来重建更改过的应用。之后运行docker-compose up -d重新启动后台进程。

    应用启动后,命令行运行docker exec gs-api "node" "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);">{ 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()调用删掉,因为最终应用会从connection模块之外调用它。

    3.1 给Reset Index添加Helper功能

    在server/connection.js文件checkConnection之下添加如下内容, 以便更加方便重置索引。

    <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我们可以定义文档每个域和数据类型。Elasticsearch是schema-less,因此技术上说不需要添加mapping,但是通过mapping可以更好控制数据处理方式。

    例如,有两个关键词域,分别是“titile”和“author”,文本定为“text”域。这样定义搜索引擎会有完全不同的动作:搜索中,引擎会在text域中查找可能匹配项,而在关键词域则是精确匹配。看起来差别不大,但却对搜索行为和搜索速度有很大影响。

    在文件最后输出功能和属性,可以被其它模块访问。

    <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. 加载源数据

    本文使用从Gutenberg项目(一个在线提供免费电子书的应用)提供的数据。包括100本经典书目,例如《80天环绕地球》、《罗密欧与朱丽叶》以及《奥德赛》等。

    4.1 下载书籍数据

    本文的数据可以从以下网站下载:
    https://cdn.patricktriest.com/data/books.zip,之后解压到项目根目录下的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);">wget https://cdn.patricktriest.com/data/books.zip
    unar books.zip
    </pre>

    4.2 预览书籍

    打开一本书,例如219-0.txt。书籍以公开访问license开始,跟着是书名、作者、发行日期、语言以及字符编码。

    <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 * * *,紧接着就是书的实际内容。

    书的最后会发现书籍结束声明: * * * END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS * * *,紧跟着是更加详细的书籍license。

    下一步将用编程方法从书中提取元数据,并且从* * *之间将书籍内容抽取出来。

    4.3 读取数据目录

    本节写一段脚本读取书籍内容添加到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重建镜像更新应用。

    7.png

    运行docker exec gs-api "node" "server/load_data.js"调用包含load_data脚本应用,应该看到Elasticsearch输出如下。随后,脚本会因为错误退出,原因是调用了一本目前还不存在的helper函数(parseBookFile)。

    8.png

    4.4 读取数据文件

    创建server/load_data.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);">/** 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>

    此函数执行以下功能:

    1. 从文件系统中读入文件
    2. 使用正则表达式抽取书名和作者
    3. 通过定位* * *,来抽取书中内容
    4. 解析出段落
    5. 清洗数据,移除空行

    最后返回一个包含书名、作者和段落列表的对象。

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

    9.png

    到这步,脚本顺利分理出书名和作者,脚本还会因为同样问题出错(调用还未定义的函数)。

    4.5 在ES中索引数据文件

    最后一步在load_data.js中添加insertBookData函数,将上一节中提取数据插入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>

    此函数索引书籍段落,包括作者、书名和段落元数据信息。使用bulk操作插入段落,比分别索引段落效率高很多。

    批量bulk索引这些段落可以使本应用运行在低配电脑上(我只有1.7G内存),如果你有高配电脑(大于4G内容),也许不用考虑批量bulk操作。

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

    10.png

    5. 搜索

    Elasticsearch已经灌入100本书籍数据(大约230000段落),本节做一些搜索操作。

    5.0 简单http查询

    首先,使用http://localhost:9200/library/ ... retty , 这里使用全文本查询关键字“Java”,输入应该如下:

    <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接口对于测试数据是否正常插入很有用,但是如果直接暴露给web应用就很危险。不应该将操作性API功能(例如直接添加和删除文档)直接暴露给应用,而应该写一段简单Node.js API接收客户端请求,(通过私网)转发给Elasticsearch进行查询。

    5.1 请求脚本

    这一节介绍如何从Node.js应用中向Elasticsearch中发送请求。首先创建新文件: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:为结果标出页码。每次查询默认返回10个结果;因此指定from为10,可以直接显示10-20的查询结果。
    2. query:具体查询关键词。
    3. operator:具体查询操作;本例中采用“and”操作符,优先显示包含所有查询关键词的结果。
    4. fuzziness:错误拼写修正级别(或者是模糊查询级别),默认是2。数值越高,允许模糊度越高;例如数值1,会对Patricc的查询返回Patrick结果。
    5. highlights:返回额外信息,其中包含HTML格式显示匹配文本信息。 可以调整这些参数看看具体的显示信息,可以查看Elastic Full-Text Query DSL获得更多信息。

    6. API

    本节提供前端代码访问的HTTP API。

    6.0 API Server

    修改server/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 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>

    这段代码导入服务依赖环境,为Koa.js Node API Server设置简单日志和错误处理机制。

    6.1 将服务端点与查询链接起来

    这一节为Server端添加服务端点,以便暴露给Elasticsearch查询服务。

    在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重启服务端。在浏览器中,调用此服务。例如:http://localhost:3000/search?term=java

    返回结果看起来应该如下:

    <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 输入验证

    此时服务端还是很脆弱,下面对输入参数进行检查,对无效或者缺失的输入进行甄别,并返回错误。

    我们使用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>

    现在如果重启服务端,并做一个缺失参数查询(http://localhost:3000/search),将会返回HTTP 400错误,例如:Invalid URL Query - child "term" fails because ["term" is required]。

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

    7. 前端应用

    /search服务端硬件可以了,本节写一段简单前端web应用测试API。

    7.0 Vue.js

    本节使用Vue.js来开发前端。创建一个新文件/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>

    应用特别简单,只是定义一些共享数据属性,添加一个接收方法以及为结果分页的功能;搜索间隔设置为100ms,以防API被频繁调用。

    解释Vue.js如何工作超出本文的范围,如果想了解相关内容,可以查看Vue.js官方文档.

    7.1 HTML

    将/public/index.html用如下内容代替:

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

    <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,应该能够看到一个简单分页返回结果。此时可以键入一些关键词进行查询测试。

    11.png

    这一步不需要重新运行docker-compose up命令使修改生效。本地public目录直接挂载在Ngnix服务器容器中,因此前端本地系统数据改变直接反应在容器化应用中。

    如果点任一个输出,没什么效果,意味着还有一些功能需要添加进应用中。

    8. 页面检查

    最好点击任何一个输出,可以查出上下文来自哪本书。

    8.0 添加Elasticsearch查询

    首先,需要定义一个从给定书中获得段落的简单查询。在server/search.js下的module.exports中加入如下内容:

    <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服务端口

    本节将把上节功能链接到API服务端口。在server/app.js中原来的/search服务端口下添加如下内容:

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

    本节添加前端查询功能,并显示书中包含查询内容的整页信息。在/public/app.js methods功能块中添加如下内容:

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

    重启应用服务器(docker-compose up -d --build),打开localhost:8080。此时如果点击搜索结果,就可以查询段落上下文。如果对查到结果感兴趣,甚至可以从查询处一直读下去。

    12.png

    恭喜!!到这一步主体框架已经搭建完毕。以上所有代码都可以从这里获得。

    9. Elasticsearch的不足

    9.0 资源消耗

    Elasticsearch是计算资源消耗的应用。官方建议至少运行在64G以上内存的设备上,不建议少于8GB内存。Elasticsearch是一个内存数据库,因此查询速度会很快,但是也会消耗大量内存。生产中,强烈推荐运行Elasticsearch集群提供高可用性、自动分片和数据冗余功能。

    我在一个1.7GB的云设备上(每月15美金)运行以上示例(search.patriktriest.com),这些资源仅是能够运行Elasticsearch节点。有时整个节点会在初始装载数据时候hang住。从我的经验看,Elasticsearch比传统的PostgreSQL和MongoDB跟消耗资源,如果需要提供理想服务效果,成本可能会很贵。

    9.1 数据库之间的同步

    对许多应用,将数据存放在Elasticsearch中并不是理想的选择。建议将ES作为交易型数据库,但是因为ES不兼容ACID标准(当扩展系统导入数据时,可能造成写入操作丢失的问题),所以也不推荐。很多场景下,ES承担着很特殊的角色,例如全文本查询,这种场景下需要某些数据从主数据库复制到Elasticsearch数据库中。

    例如,假设我们需要将用户存放到PostgreSQL表中,但是使用ES承担用户查询功能。如果一个用户,“Albert”,决定修改名字为“Al”,就需要在主PostgreSQL库和ES集群中同时进行修改。

    这个操作有些复杂,依赖现有的软件栈。有许多开源资源可选,既有监控MongoDB操作日志并自动同步删除数据到ES的进程,到创建客制化基于PSQL索引自动与ES通讯的PostgreSQL插件。

    如果之前提到的选项都无效,可以在服务端代码中根据数据库变化手动更新Elasticsearch索引。但是我认为这种选择并不是最佳的,因为使用客制化商业逻辑保持ES同步很复杂,而且有可能会引入很多bugs。

    Elasticsearch与主数据库同步需求,与其说是ES的弱点,不如说是架构复杂造成的;给应用添加一个专用搜索引擎是一件值得考虑的事情,但是要折衷考虑带来的问题。

    结论

    全文本搜索对现代应用来说是一个很重要的功能,同时也是很难完成的功能。Elasticsearch则提供了实现快速和客制化搜索的实现方式,但是也有其它替代选项。Apache Solr是另外一个基于Apache Lucene(Elasticsearch核心也采用同样的库)实现的开源类似实现。Algolia则是最近很活跃的search-as-a-service模式web平台,对初学者来说更加容易上手(缺点是客制化不强,而且后期投入可能很大)。

    “search-bar”模式功能远不仅是Elasticsearch的唯一使用场景。ES也是一个日志存储和分析常用工具,一般用于ELK架构(Elasticsearch,Logstash,Kibana)。ES实现的灵活全文本搜索对数据科学家任务也很有用,例如修改、规范化数据集拼写或者搜索数据集。

    如下是有关本项目的考虑:

    1. 在应用中添加更多喜爱的书,创建自己私有库搜索引擎。
    2. 通过索引Google Scholar论文,创建一个防抄袭引擎。
    3. 通过索引字典中单词到ES中,建立拼写检查应用。
    4. 通过加载Common Crawl Corpus到ES(注意,有50亿页内容,是一个非常巨大数据集),建立自己的与谷歌竞争的互联网搜索引擎。
    5. 在新闻业中使用Elasticsearch:在例如Panama论文和Paradise论文集中搜索特点名称和词条。

    本文所有代码都是开源的,可以在GitHub库中找到,具体下载地址。希望本文对大家有所帮助。

    作者简介:Patrick Triest是一位全栈工程师,数据爱好者,持续学习者,洁癖编程者。作者github地址为https://github.com/triestpa

    相关文章

      网友评论

          本文标题:使用Docker和Elasticsearch搭建全文本搜索引擎应

          本文链接:https://www.haomeiwen.com/subject/khcsjqtx.html