使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

  • 使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序已关闭评论
  • 41 views
  • A+
所属分类:elasticsearch Kubernetes

在本教程中,我们将带你探索如何配置我们自己的全文搜索应用程序(与上述问题中的系统相比,它的复杂度要小很多)。我们的示例应用程序将提供一个 UI 和 API 去从 100 部经典文学(比如,《彼得·潘》 、  《弗兰肯斯坦》 和  《金银岛》)中搜索完整的文本。

你可以在这里(https://search.patricktriest.com)预览该教程应用的完整版本。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

preview webapp

这个应用程序的源代码是 100% 开源的,可以在 GitHub 仓库上找到它们 —— https://github.com/triestpa/guttenberg-search

在应用程序中添加一个快速灵活的全文搜索可能是个挑战。大多数的主流数据库,比如,PostgreSQL 和 MongoDB,由于受其查询和索引结构的限制只能提供一个非常基础的文本搜索功能。为实现高质量的全文搜索,通常的最佳选择是单独的数据存储。Elasticsearch 是一个开源数据存储的领导者,它专门为执行灵活而快速的全文搜索进行了优化。

我们将使用 Docker 去配置我们自己的项目环境和依赖。Docker 是一个容器化引擎,它被 UberSpotifyADP 以及 Paypal 使用。构建容器化应用的一个主要优势是,项目的设置在 Windows、macOS、以及 Linux 上都是相同的 —— 这使我写这个教程快速又简单。如果你还没有使用过 Docker,不用担心,我们接下来将经历完整的项目配置。

我也会使用 Node.js (使用 Koa 框架)和 Vue.js,用它们分别去构建我们自己的搜索 API 和前端 Web 应用程序。

1 - Elasticsearch 是什么?

全文搜索在现代应用程序中是一个有大量需求的特性。搜索也可能是最难的一项特性 —— 许多流行的网站的搜索功能都不合格,要么返回结果太慢,要么找不到精确的结果。通常,这种情况是被底层的数据库所局限:大多数标准的关系型数据库局限于基本的 CONTAINS 或 LIKE SQL 查询上,它仅提供最基本的字符串匹配功能。

我们的搜索应用程序将具备:

  1. 快速 - 搜索结果将快速返回,为用户提供一个良好的体验。
  2. 灵活 - 我们希望能够去修改搜索如何执行的方式,这是为了便于在不同的数据库和用户场景下进行优化。
  3. 容错 - 如果所搜索的内容有拼写错误,我们将仍然会返回相关的结果,而这个结果可能正是用户希望去搜索的结果。
  4. 全文 - 我们不想限制我们的搜索只能与指定的关键字或者标签相匹配 —— 我们希望它可以搜索在我们的数据存储中的任何东西(包括大的文本字段)。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

Elastic Search Logo

为了构建一个功能强大的搜索功能,通常最理想的方法是使用一个为全文搜索任务优化过的数据存储。在这里我们使用 Elasticsearch,Elasticsearch 是一个开源的内存中的数据存储,它是用 Java 写的,最初是在 Apache Lucene 库上构建的。

这里有一些来自 Elastic 官方网站 上的 Elasticsearch 真实使用案例。

  • Wikipedia 使用 Elasticsearch 去提供带高亮搜索片断的全文搜索功能,并且提供按类型搜索和 “did-you-mean” 建议。
  • Guardian 使用 Elasticsearch 把社交网络数据和访客日志相结合,为编辑去提供新文章的公众意见的实时反馈。
  • Stack Overflow 将全文搜索和地理查询相结合,并使用 “类似” 的方法去找到相关的查询和回答。
  • GitHub 使用 Elasticsearch 对 1300 亿行代码进行查询。

与 “普通的” 数据库相比,Elasticsearch 有什么不一样的地方?

Elasticsearch 之所以能够提供快速灵活的全文搜索,秘密在于它使用反转索引inverted index

“索引” 是数据库中的一种数据结构,它能够以超快的速度进行数据查询和检索操作。数据库通过存储与表中行相关联的字段来生成索引。在一种可搜索的数据结构(一般是 B 树)中排序索引,在优化过的查询中,数据库能够达到接近线性的时间(比如,“使用 ID=5 查找行”)。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

Relational Index

我们可以将数据库索引想像成一个图书馆中老式的卡片式目录 —— 只要你知道书的作者和书名,它就会告诉你书的准确位置。为加速特定字段上的查询速度,数据库表一般有多个索引(比如,在 name 列上的索引可以加速指定名字的查询)。

反转索引本质上是不一样的。每行(或文档)的内容是分开的,并且每个独立的条目(在本案例中是单词)反向指向到包含它的任何文档上。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

Inverted Index

这种反转索引数据结构可以使我们非常快地查询到,所有出现 “football” 的文档。通过使用大量优化过的内存中的反转索引,Elasticsearch 可以让我们在存储的数据上,执行一些非常强大的和自定义的全文搜索。

2 - 项目设置

2.0 - Docker

我们在这个项目上使用 Docker 管理环境和依赖。Docker 是个容器引擎,它允许应用程序运行在一个独立的环境中,不会受到来自主机操作系统和本地开发环境的影响。现在,许多公司将它们的大规模 Web 应用程序主要运行在容器架构上。这样将提升灵活性和容器化应用程序组件的可组构性。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

Docker Logo

对我来说,使用 Docker 的优势是,它对本教程的作者非常方便,它的本地环境设置量最小,并且跨 Windows、macOS 和 Linux 系统的一致性很好。我们只需要在 Docker 配置文件中定义这些依赖关系,而不是按安装说明分别去安装 Node.js、Elasticsearch 和 Nginx,然后,就可以使用这个配置文件在任何其它地方运行我们的应用程序。而且,因为每个应用程序组件都运行在它自己的独立容器中,它们受本地机器上的其它 “垃圾” 干扰的可能性非常小,因此,在调试问题时,像“它在我这里可以工作!”这类的问题将非常少。

2.1 - 安装 Docker & Docker-Compose

这个项目只依赖 Docker 和 docker-compose,docker-compose 是 Docker 官方支持的一个工具,它用来将定义的多个容器配置 组装  成单一的应用程序栈。

2.2 - 设置项目主目录

为项目创建一个主目录(名为 guttenberg_search)。我们的项目将工作在主目录的以下两个子目录中。

  • /public - 保存前端 Vue.js Web 应用程序。
  • /server - 服务器端 Node.js 源代码。

2.3 - 添加 Docker-Compose 配置

接下来,我们将创建一个 docker-compose.yml 文件来定义我们的应用程序栈中的每个容器。

  1. gs-api - 后端应用程序逻辑使用的 Node.js 容器
  2. gs-frontend - 前端 Web 应用程序使用的 Ngnix 容器。
  3. gs-search - 保存和搜索数据的 Elasticsearch 容器。
version: '3'
services:
  api: # Node.js App
    container_name: gs-api
    build: .
    ports:
      - "3000:3000" # Expose API port
      - "9229:9229" # Expose Node process debug port (disable in production)
    environment: # Set ENV vars
     - NODE_ENV=local
     - ES_HOST=elasticsearch
     - PORT=3000
    volumes: # Attach local book data directory
      - ./books:/usr/src/app/books

  frontend: # Nginx Server For Frontend App
    container_name: gs-frontend
    image: nginx
    volumes: # Serve local "public" dir
      - ./public:/usr/share/nginx/html
    ports:
      - "8080:80" # Forward site to localhost:8080

  elasticsearch: # Elasticsearch Instance
    container_name: gs-search
    image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
    volumes: # Persist ES data in seperate "esdata" volume
      - esdata:/usr/share/elasticsearch/data
    environment:
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
      - discovery.type=single-node
    ports: # Expose Elasticsearch ports
      - "9300:9300"
      - "9200:9200"

volumes: # Define seperate volume for Elasticsearch data
  esdata:

这个文件定义了我们全部的应用程序栈 —— 不需要在你的本地系统上安装 Elasticsearch、Node 和 Nginx。每个容器都将端口转发到宿主机系统(localhost)上,以便于我们在宿主机上去访问和调试 Node API、Elasticsearch 实例和前端 Web 应用程序。

2.4 - 添加 Dockerfile

对于 Nginx 和 Elasticsearch,我们使用了官方预构建的镜像,而 Node.js 应用程序需要我们自己去构建。

在应用程序的根目录下定义一个简单的 Dockerfile 配置文件。

# Use Node v8.9.0 LTS
FROM node:carbon

# Setup app working directory
WORKDIR /usr/src/app

# Copy package.json and package-lock.json
COPY package*.json ./

# Install app dependencies
RUN npm install

# Copy sourcecode
COPY . .

# Start app
CMD [ "npm", "start" ]

这个 Docker 配置扩展了官方的 Node.js 镜像、拷贝我们的应用程序源代码、以及在容器内安装 NPM 依赖。

我们也增加了一个 .dockerignore 文件,以防止我们不需要的文件拷贝到容器中。

node_modules/
npm-debug.log
books/
public/

请注意:我们之所以不拷贝 node_modules 目录到我们的容器中 —— 是因为我们要在容器构建过程里面运行 npm install。从宿主机系统拷贝 node_modules 到容器里面可能会引起错误,因为一些包需要为某些操作系统专门构建。比如说,在 macOS 上安装 bcrypt 包,然后尝试将这个模块直接拷贝到一个 Ubuntu 容器上将不能工作,因为 bcyrpt 需要为每个操作系统构建一个特定的二进制文件。

2.5 - 添加基本文件

为了测试我们的配置,我们需要添加一些占位符文件到应用程序目录中。

在 public/index.html 文件中添加如下内容。

<html><body>Hello World From The Frontend Container</body></html>

接下来,在 server/app.js 中添加 Node.js 占位符文件。

const Koa = require('koa')
const app = new Koa()

app.use(async (ctx, next) => {
  ctx.body = 'Hello World From the Backend Container'
})

const port = process.env.PORT || 3000

app.listen(port, err => {
  if (err) console.error(err)
  console.log(`App Listening on Port ${port}`)
})

最后,添加我们的 package.json  Node 应用配置。

{
  "name": "guttenberg-search",
  "version": "0.0.1",
  "description": "Source code for Elasticsearch tutorial using 100 classic open source books.",
  "scripts": {
    "start": "node --inspect=0.0.0.0:9229 server/app.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/triestpa/guttenberg-search.git"
  },
  "author": "patrick.triest@gmail.com",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/triestpa/guttenberg-search/issues"
  },
  "homepage": "https://github.com/triestpa/guttenberg-search#readme",
  "dependencies": {
    "elasticsearch": "13.3.1",
    "joi": "13.0.1",
    "koa": "2.4.1",
    "koa-joi-validate": "0.5.1",
    "koa-router": "7.2.1"
  }
}

这个文件定义了应用程序启动命令和 Node.js 包依赖。

注意:不要运行 npm install —— 当它构建时,依赖会在容器内安装。

2.6 - 测试它的输出

现在一切新绪,我们来测试应用程序的每个组件的输出。从应用程序的主目录运行 docker-compose build,它将构建我们的 Node.js 应用程序容器。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

docker build output

接下来,运行 docker-compose up 去启动整个应用程序栈。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

docker compose output

这一步可能需要几分钟时间,因为 Docker 要为每个容器去下载基础镜像。以后再次运行,启动应用程序会非常快,因为所需要的镜像已经下载完成了。

在你的浏览器中尝试访问 localhost:8080 —— 你将看到简单的 “Hello World” Web 页面。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

 

访问 localhost:3000 去验证我们的 Node 服务器,它将返回 “Hello World” 信息。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

最后,访问 localhost:9200 去检查 Elasticsearch 运行状态。它将返回类似如下的内容。

{
  "name" : "SLTcfpI",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "iId8e0ZeS_mgh9ALlWQ7-w",
  "version" : {
    "number" : "6.1.1",
    "build_hash" : "bd92e7f",
    "build_date" : "2017-12-17T20:23:25.338Z",
    "build_snapshot" : false,
    "lucene_version" : "7.1.0",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}

如果三个 URL 都显示成功,祝贺你!整个容器栈已经正常运行了,接下来我们进入最有趣的部分。

3 - 连接到 Elasticsearch

我们要做的第一件事情是,让我们的应用程序连接到我们本地的 Elasticsearch 实例上。

3.0 - 添加 ES 连接模块

在新文件 server/connection.js 中添加如下的 Elasticsearch 初始化代码。

const elasticsearch = require('elasticsearch')
// Core ES variables for this project
const index = 'library'
const type = 'novel'
const port = 9200
const host = process.env.ES_HOST || 'localhost'
const client = new elasticsearch.Client({ host: { host, port } })

/** Check the ES connection status */
async function checkConnection () {
  let isConnected = false
  while (!isConnected) {
    console.log('Connecting to ES')
    try {
      const health = await client.cluster.health({})
      console.log(health)
      isConnected = true
    } catch (err) {
      console.log('Connection Failed, Retrying...', err)
    }
  }
}
checkConnection()

现在,我们重新构建我们的 Node 应用程序,我们将使用 docker-compose build 来做一些改变。接下来,运行 docker-compose up -d 去启动应用程序栈,它将以守护进程的方式在后台运行。

应用程序启动之后,在命令行中运行 docker exec gs-api "node" "server/connection.js",以便于在容器内运行我们的脚本。你将看到类似如下的系统输出信息。

{ cluster_name: 'docker-cluster',
  status: 'yellow',
  timed_out: false,
  number_of_nodes: 1,
  number_of_data_nodes: 1,
  active_primary_shards: 1,
  active_shards: 1,
  relocating_shards: 0,
  initializing_shards: 0,
  unassigned_shards: 1,
  delayed_unassigned_shards: 0,
  number_of_pending_tasks: 0,
  number_of_in_flight_fetch: 0,
  task_max_waiting_in_queue_millis: 0,
  active_shards_percent_as_number: 50 }

继续之前,我们先删除最下面的 checkConnection() 调用,因为,我们最终的应用程序将调用外部的连接模块。

3.1 - 添加函数去重置索引

在 server/connection.js 中的 checkConnection 下面添加如下的函数,以便于重置 Elasticsearch 索引。

/** Clear the index, recreate it, and add mappings */
async function resetIndex (index) {
  if (await client.indices.exists({ index })) {
    await client.indices.delete({ index })
  }

  await client.indices.create({ index })
  await putBookMapping()
}

3.2 - 添加图书模式

接下来,我们将为图书的数据模式添加一个 “映射”。在 server/connection.js 中的 resetIndex 函数下面添加如下的函数。

/** Add book section schema mapping to ES */
async function putBookMapping () {
  const schema = {
    title: { type: 'keyword' },
    author: { type: 'keyword' },
    location: { type: 'integer' },
    text: { type: 'text' }
  }
  return client.indices.putMapping({ index, type, body: { properties: schema } })
}

这是为 book 索引定义了一个映射。Elasticsearch 中的 index 大概类似于 SQL 的 table 或者 MongoDB 的  collection。我们通过添加映射来为存储的文档指定每个字段和它的数据类型。Elasticsearch 是无模式的,因此,从技术角度来看,我们是不需要添加映射的,但是,这样做,我们可以更好地控制如何处理数据。

比如,我们给 title 和 author 字段分配 keyword 类型,给 text 字段分配 text 类型。之所以这样做的原因是,搜索引擎可以区别处理这些字符串字段 —— 在搜索的时候,搜索引擎将在 text 字段中搜索可能的匹配项,而对于 keyword 类型字段,将对它们进行全文匹配。这看上去差别很小,但是它们对在不同的搜索上的速度和行为的影响非常大。

在文件的底部,导出对外发布的属性和函数,这样我们的应用程序中的其它模块就可以访问它们了。

module.exports = {
  client, index, type, checkConnection, resetIndex
}

4 - 加载原始数据

我们将使用来自 古登堡项目 的数据 ——  它致力于为公共提供免费的线上电子书。在这个项目中,我们将使用 100 本经典图书来充实我们的图书馆,包括《福尔摩斯探案集》、《金银岛》、《基督山复仇记》、《环游世界八十天》、《罗密欧与朱丽叶》 和《奥德赛》。

使用 Docker 和 Elasticsearch 构建一个全文搜索应用程序

Book Covers

4.1 - 下载图书文件

我将这 100 本书打包成一个文件,你可以从这里下载它 —— https://cdn.patricktriest.com/data/books.zip

将这个文件解压到你的项目的 books/ 目录中。

你可以使用以下的命令来完成(需要在命令行下使用 wget 和 The Unarchiver)。

wget https://cdn.patricktriest.com/data/books.zip
unar books.zip

4.2 - 预览一本书

尝试打开其中的一本书的文件,假设打开的是 219-0.txt。你将注意到它开头是一个公开访问的协议,接下来是一些标识这本书的书名、作者、发行日期、语言和字符编码的行。

Title: Heart of Darkness
Author: Joseph Conrad

Release Date: February 1995 [EBook #219]
Last Updated: September 7, 2016
Language: English
Character set encoding: UTF-8

在 *** START OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS *** 这些行后面,是这本书的正式内容。

如果你滚动到本书的底部,你将看到类似 *** END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS ***信息,接下来是本书更详细的协议版本。

下一步,我们将使用程序从文件头部来解析书的元数据,提取 *** START OF 和 ***END OF 之间的内容。

4.3 - 读取数据目录

我们将写一个脚本来读取每本书的内容,并将这些数据添加到 Elasticsearch。我们将定义一个新的 Javascript 文件 server/load_data.js 来执行这些操作。

首先,我们将从 books/ 目录中获取每个文件的列表。

在 server/load_data.js 中添加下列内容。

const fs = require('fs')
const path = require('path')
const esConnection = require('./connection')

/** Clear ES index, parse and index all files from the books directory */
async function readAndInsertBooks () {
  try {
    // Clear previous ES index
    await esConnection.resetIndex()
    // Read books directory
    let files = fs.readdirSync('./books').filter(file => file.slice(-4) === '.txt')
    console.log(`Found ${files.length} Files`)
    // Read each book file, and index each paragraph in elasticsearch
    for (let file of files) {
      console.log(`Reading File - ${file}`)
      const filePath = path.join('./books', file)
      const { title, author, paragraphs } = parseBookFile(filePath)
      await insertBookData(title, author, paragraphs)
    }
  } catch (err) {
    console.error(err)
  }
}
readAndInsertBooks()

我们将使用一个快捷命令来重构我们的 Node.js 应用程序,并更新运行的容器。

运行 docker-compose up -d --build 去更新应用程序。这是运行  docker-compose build