在 Docker 上使用 Node.js 和 ElasticSearch 进行全文搜索
作者:Michele Riva✏️
全文搜索既令人恐惧又令人兴奋。一些流行的数据库(例如MySql和Postgres)是出色的数据存储解决方案……但就全文搜索性能而言,ElasticSearch无人能及。
对于那些不了解ElasticSearch 的人来说,它是一款基于Lucene构建的搜索引擎服务器,拥有出色的分布式架构支持。根据db-engines.com的数据,它是目前使用最广泛的搜索引擎。
在这篇文章中,我们将构建一个名为“报价数据库”的简单 REST 应用程序,它允许我们存储和搜索任意数量的报价。
我准备了一个JSON 文件,其中包含 5000 多条引言及其作者,我们将使用它作为填充 ElasticSearch 的起始数据。
您可以在此处找到该项目的存储库。
设置 Docker
首先,我们不想在机器上安装 ElasticSearch。我们将使用 Docker 在容器中编排 Node.js 服务器和 ElasticSearch 实例,这将使我们能够部署一个包含所有依赖项的生产就绪应用程序。
让我们Dockerfile
在项目根文件夹中创建一个:
FROM node:10.15.3-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
RUN npm install -g pm2
COPY . ./
EXPOSE 3000
EXPOSE 9200
CMD npm run start
如你所见,我们告诉 Docker 我们将运行Node.js 10.15.3-alpine运行时。我们还将在 下创建一个新的工作目录/usr/src/app
,并将package.json
和package-lock.json
文件复制到其中。这样,Docker 就能npm install
在我们的 中运行WORKDIR
,并安装所需的依赖项。
我们还将通过运行来安装PM2。Node.jsRUN npm install -g pm2
运行时是单线程的,因此如果某个进程崩溃,则需要重新启动整个应用程序…… PM2会检查 Node.js 进程状态,并在应用程序因任何原因崩溃时重新启动它。
安装 PM2 后,我们将代码库复制到我们的WORKDIR
(COPY . ./
)中,并告诉 Docker 公开两个端口,3000
这将公开我们的 RESTful 服务,以及9200
,这将公开 ElasticSearch 服务(EXPOSE 3000
和EXPOSE 9200
)。
最后但同样重要的一点是,我们告诉 Docker 哪个命令将启动 Node.js 应用程序npm run start
。
设置docker-compose
现在你可能会说:“太好了,我明白了!但是我该如何在 Docker 中处理 ElasticSearch 实例呢?我在 Dockerfile 中找不到它!” ……你说得对!这就是docker-compose 的用武之地。它允许我们编排多个 Docker 容器并在它们之间建立连接。所以,让我们写下这个docker-compose.yml
文件,它将存储在我们的项目根目录中:
version: '3.6'
services:
api:
image: node:10.15.3-alpine
container_name: tqd-node
build: .
ports:
- 3000:3000
environment:
- NODE_ENV=local
- ES_HOST=elasticsearch
- NODE_PORT=3000
- ELASTIC_URL=http://elasticsearch:9200
volumes:
- .:/usr/src/app/quotes
command: npm run start
links:
- elasticsearch
depends_on:
- elasticsearch
networks:
- esnet
elasticsearch:
container_name: tqd-elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch:7.0.1
volumes:
- esdata:/usr/share/elasticsearch/data
environment:
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- discovery.type=single-node
logging:
driver: none
ports:
- 9300:9300
- 9200:9200
networks:
- esnet
volumes:
esdata:
networks:
esnet:
这比我们的 Dockerfile 稍微复杂一些,但让我们分析一下:
- 我们声明
docker-compose.yml
正在使用哪个版本的文件(3.6
) - 我们声明我们的服务:
api
,这就是我们的 Node.js 应用。就像在 Dockerfile 中一样,它需要node:10.15.3-alpine
镜像。我们还为这个容器分配了一个名称tqd-node
,然后使用build .
命令调用之前创建的 Dockerfile。- 我们需要公开
3000
端口,因此我们将这些语句编写如下3000:3000
。这意味着我们将从端口(容器内部)映射到端口3000
(可从3000
我们的机器访问)。然后我们将设置一些环境变量。该值elasticsearch
是一个引用文件elasticsearch
内部服务的变量docker-compose.yml
。 - 我们还想挂载一个卷
/usr/src/app/quotes
。这样,一旦我们重启容器,我们就能保留数据而不会丢失。 - 再次,我们告诉 Docker 容器启动后需要执行哪个命令,然后设置到该服务的链接。我们还告诉 Docker 在服务启动后
elasticsearch
启动该服务(使用指令)。api
elasticsearch
depends_on
- 最后但同样重要的是,我们告诉 Docker 将
api
服务连接到该esnet
网络。这是因为每个容器都有自己的网络。这样,我们就可以称容器api
和elasticsearch
服务共享同一个网络,以便它们能够使用相同的端口相互调用。 elasticsearch
,也就是(你可能已经猜到了)我们的 ES 服务。它的配置与 ESapi
服务非常相似。我们只需将logging
指令设置为 即可截断其详细日志driver: none
。
- 我们还声明了我们的卷,用于存储 ES 数据。
- 我们宣布我们的网络,
esnet
。
引导 Node.js 应用程序
现在我们需要创建我们的 Node.js 应用程序,所以让我们开始设置我们的package.json
文件:
npm init -y
现在我们需要安装一些依赖项:
npm i -s @elastic/elasticsearch body-parser cors dotenv express
太棒了!我们的package.json
文件应该如下所示:
{
"name": "nodejselastic",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@elastic/elasticsearch": "^7.3.0",
"body-parser": "^1.19.0",
"cors": "^2.8.5",
"dotenv": "^8.0.0",
"express": "^4.17.1"
}
}
让我们在 Node.js 中实现 ElasticSearch 连接器。首先,我们需要创建一个新/src/elastic.js
文件:
const { Client } = require("@elastic/elasticsearch");
require("dotenv").config();
const elasticUrl = process.env.ELASTIC_URL || "http://localhost:9200";
const esclient = new Client({ node: elasticUrl });
const index = "quotes";
const type = "quotes";
如你所见,我们在这里设置了一些非常有用的常量。首先,我们使用 ElasticSearch 的官方Node.js SDK创建一个新连接,然后定义一个索引("quotes"
)和一个索引类型("quotes"
同样,我们稍后会了解它们的含义)。
现在我们需要在 ElasticSearch 上创建索引。你可以将“索引”理解为 SQL 中的“数据库”。ElasticSearch 是一个 NoSQL 数据库,这意味着它没有表,只存储 JSON 文档。索引是一个逻辑命名空间,它映射到一个或多个主分片,并且可以有零个或多个副本分片。你可以在这里阅读更多关于 ElasticSearch 索引的内容。
现在让我们定义一个创建索引的函数:
async function createIndex(index) {
try {
await esclient.indices.create({ index });
console.log(`Created index ${index}`);
} catch (err) {
console.error(`An error occurred while creating the index ${index}:`);
console.error(err);
}
}
现在我们需要另一个函数来创建引文的映射。该映射定义了文档的模式和类型:
async function setQuotesMapping () {
try {
const schema = {
quote: {
type: "text"
},
author: {
type: "text"
}
};
await esclient.indices.putMapping({
index,
type,
include_type_name: true,
body: {
properties: schema
}
})
console.log("Quotes mapping created successfully");
} catch (err) {
console.error("An error occurred while setting the quotes mapping:");
console.error(err);
}
}
正如您所见,我们正在为我们的文档定义模式,并将其插入到我们的文件中index
。
现在我们假设 ElasticSearch 是一个庞大的系统,启动可能需要几秒钟。在 ES 准备就绪之前我们无法连接到它,因此我们需要一个函数来检查 ES 服务器是否已准备就绪:
function checkConnection() {
return new Promise(async (resolve) => {
console.log("Checking connection to ElasticSearch...");
let isConnected = false;
while (!isConnected) {
try {
await esclient.cluster.health({});
console.log("Successfully connected to ElasticSearch");
isConnected = true;
// eslint-disable-next-line no-empty
} catch (_) {
}
}
resolve(true);
});
}
如你所见,我们返回了一个 Promise。这是因为通过使用 ,async/await
我们可以停止整个 Node.js 进程,直到这个 Promise 解析完成,并且只有在连接到 ES 后才会解析。这样,我们就强制 Node.js 等待 ES 完成解析后再启动。
ElasticSearch 已经搞定!现在导出函数:
module.exports = {
esclient,
setQuotesMapping,
checkConnection,
createIndex,
index,
type
};
太棒了!让我们看看整个elastic.js
文件:
const { Client } = require("@elastic/elasticsearch");
require("dotenv").config();
const elasticUrl = process.env.ELASTIC_URL || "http://localhost:9200";
const esclient = new Client({ node: elasticUrl });
const index = "quotes";
const type = "quotes";
/**
* @function createIndex
* @returns {void}
* @description Creates an index in ElasticSearch.
*/
async function createIndex(index) {
try {
await esclient.indices.create({ index });
console.log(`Created index ${index}`);
} catch (err) {
console.error(`An error occurred while creating the index ${index}:`);
console.error(err);
}
}
/**
* @function setQuotesMapping,
* @returns {void}
* @description Sets the quotes mapping to the database.
*/
async function setQuotesMapping () {
try {
const schema = {
quote: {
type: "text"
},
author: {
type: "text"
}
};
await esclient.indices.putMapping({
index,
type,
include_type_name: true,
body: {
properties: schema
}
})
console.log("Quotes mapping created successfully");
} catch (err) {
console.error("An error occurred while setting the quotes mapping:");
console.error(err);
}
}
/**
* @function checkConnection
* @returns {Promise<Boolean>}
* @description Checks if the client is connected to ElasticSearch
*/
function checkConnection() {
return new Promise(async (resolve) => {
console.log("Checking connection to ElasticSearch...");
let isConnected = false;
while (!isConnected) {
try {
await esclient.cluster.health({});
console.log("Successfully connected to ElasticSearch");
isConnected = true;
// eslint-disable-next-line no-empty
} catch (_) {
}
}
resolve(true);
});
}
module.exports = {
esclient,
setQuotesMapping,
checkConnection,
createIndex,
index,
type
};
使用引号填充 ElasticSearch
现在我们需要将引言填充到 ES 实例中。这听起来可能很简单,但相信我,它确实很棘手。
让我们创建一个新文件/src/data/index.js
:
const elastic = require("../elastic");
const quotes = require("./quotes.json");
const esAction = {
index: {
_index: elastic.index,
_type: elastic.type
}
};
如您所见,我们导入了elastic
刚刚创建的模块以及存储在 JSON 文件中的引文/src/data/quotes.json
。我们还创建了一个名为 的对象esAction
,它将告诉 ES 如何在插入文档后对其进行索引。
现在我们需要一个脚本来填充数据库。我们还需要创建一个具有以下结构的对象数组:
[
{
index: {
_index: elastic.index,
_type: elastic.type
}
},
{
author: "quote author",
quote: "quote"
},
...
]
如你所见,对于我们要插入的每条引文,我们都需要将其映射到 ElasticSearch。因此,我们需要这样做:
async function populateDatabase() {
const docs = [];
for (const quote of quotes) {
docs.push(esAction);
docs.push(quote);
}
return elastic.esclient.bulk({ body: docs });
}
太棒了!现在让我们创建主文件/src/main.js
,看看如何构建到目前为止所写的内容:
const elastic = require("./elastic");
const data = require("./data");
require("dotenv").config();
(async function main() {
const isElasticReady = await elastic.checkConnection();
if (isElasticReady) {
const elasticIndex = await elastic.esclient.indices.exists({index: elastic.index});
if (!elasticIndex.body) {
await elastic.createIndex(elastic.index);
await elastic.setQuotesMapping();
await data.populateDatabase()
}
}
})();
让我们分析一下上面的代码。我们创建一个自执行的 main 函数,用于检查 ES 连接。只有在 ES 连接成功后,代码才会执行。ES 准备就绪后,我们会检查quotes
索引是否存在。如果不存在,我们会创建索引,设置其映射并填充数据库。显然,我们只会在第一次启动服务器时执行这些操作!
创建 RESTful API
现在我们需要创建 RESTful 服务器。我们将使用 Express.js,它是目前最流行的 Node.js 服务器构建框架。
我们将从该文件开始/src/server/index.js
:
const express = require("express");
const cors = require("cors");
const bodyParser = require("body-parser");
const routes = require("./routes");
require("dotenv").config();
const app = express();
const port = process.env.NODE_PORT || 3000;
function start() {
return app.use(cors())
.use(bodyParser.urlencoded({ extended: false }))
.use(bodyParser.json())
.use("/quotes",routes)
.use((_req, res) => res.status(404).json({ success: false,error: "Route not found" }))
.listen(port, () => console.log(`Server ready on port ${port}`));
}
module.exports = {
start
};
如您所见,它只是一个标准的 Express.js 服务器,我们不会在其上花费太多时间。
让我们看看我们的/src/server/routes/index.js
文件:
const express = require("express");
const controller = require("../controllers");
const routes = express.Router();
routes.route("/").get(controller.getQuotes);
routes.route("/new").post(controller.addQuote);
module.exports = routes;
我们只需创建两个端点:
GET /
,将返回与我们的查询字符串参数匹配的引号列表。POST /new/
,将允许我们发布新的报价并将其存储在 ElasticSearch 中。
现在让我们看看我们的/src/server/controllers/index.js
文件:
const model = require("../models");
async function getQuotes(req, res) {
const query = req.query;
if (!query.text) {
res.status(422).json({
error: true,
data: "Missing required parameter: text"
});
return;
}
try {
const result = await model.getQuotes(req.query);
res.json({ success: true, data: result });
} catch (err) {
res.status(500).json({ success: false, error: "Unknown error."});
}
}
async function addQuote(req, res) {
const body = req.body;
if (!body.quote || !body.author) {
res.status(422).json({
error: true,
data: "Missing required parameter(s): 'body' or 'author'"
});
return;
}
try {
const result = await model.insertNewQuote(body.quote, body.author);
res.json({
success: true,
data: {
id: result.body._id,
author: body.author,
quote: body.quote
}
});
} catch (err) {
res.status(500).json({ success: false, error: "Unknown error."});
}
}
module.exports = {
getQuotes,
addQuote
};
这里我们主要定义两个函数:
getQuotes
,它至少需要一个查询字符串参数 –text
addQuote
,需要两个参数——author
和quote
ElasticSearch 接口委托给了我们的/src/server/models/index.js
。这种结构有助于我们维护一个类似 MVC 的架构。
让我们看看我们的模型:
const { esclient, index, type } = require("../../elastic");
async function getQuotes(req) {
const query = {
query: {
match: {
quote: {
query: req.text,
operator: "and",
fuzziness: "auto"
}
}
}
}
const { body: { hits } } = await esclient.search({
from: req.page || 0,
size: req.limit || 100,
index: index,
type: type,
body: query
});
const results = hits.total.value;
const values = hits.hits.map((hit) => {
return {
id: hit._id,
quote: hit._source.quote,
author: hit._source.author,
score: hit._score
}
});
return {
results,
values
}
}
如您所见,我们通过选择包含给定单词或短语的每个引文来编写 ElasticSearch 查询。
然后,我们生成查询,设置page
和的limit
值,例如,我们可以将它们传递给查询字符串http://localhost:3000/quotes?text=love&page=1&limit=100
。如果这些值不是通过查询字符串传递的,我们将恢复为它们的默认值。
ElasticSearch 返回的数据量非常大,但我们只需要四样东西:
- 报价编号
- 引文本身
- 引述作者
- 分数
分数表示引文与搜索词的接近程度。获得这些值后,我们会将其与结果总数一起返回,这在前端对结果进行分页时可能会很有用。
现在我们需要为我们的模型创建最后一个函数insertNewQuote
:
async function insertNewQuote(quote, author) {
return esclient.index({
index,
type,
body: {
quote,
author
}
})
}
这个功能非常简单,我们只需将引文和作者发布到我们的索引并将查询结果返回给控制器。
现在完整的/src/server/models/index.js
文件应该如下所示:
const { esclient, index, type } = require("../../elastic");
async function getQuotes(req) {
const query = {
query: {
match: {
quote: {
query: req.text,
operator: "and",
fuzziness: "auto"
}
}
}
}
const { body: { hits } } = await esclient.search({
from: req.page || 0,
size: req.limit || 100,
index: index,
type: type,
body: query
});
const results = hits.total.value;
const values = hits.hits.map((hit) => {
return {
id: hit._id,
quote: hit._source.quote,
author: hit._source.author,
score: hit._score
}
});
return {
results,
values
}
}
async function insertNewQuote(quote, author) {
return esclient.index({
index,
type,
body: {
quote,
author
}
})
}
module.exports = {
getQuotes,
insertNewQuote
}
大功告成!我们只需要在package.json
文件中设置好启动脚本,就可以开始使用了:
"scripts": {
"start": "pm2-runtime start ./src/main.js --name node_app",
"stop": "pm2-runtime stop node_app "
}
我们还需要更新/src/main.js
脚本,以便在 ElasticSearch 连接后启动 Express.js 服务器:
const elastic = require("./elastic");
const server = require("./server");
const data = require("./data");
require("dotenv").config();
(async function main() {
const isElasticReady = await elastic.checkConnection();
if (isElasticReady) {
const elasticIndex = await elastic.esclient.indices.exists({index: elastic.index});
if (!elasticIndex.body) {
await elastic.createIndex(elastic.index);
await elastic.setQuotesMapping();
await data.populateDatabase()
}
server.start();
}
})();
启动应用程序
我们现在准备使用docker-compose启动我们的应用程序!
只需运行以下命令:
docker-compose up
您需要等到 Docker 下载 ElasticSearch 和 Node.js 镜像,然后它将启动您的服务器,您就可以查询您的 REST 端点了!
让我们用几个 cURL 调用来测试一下:
curl localhost:3000/quotes?text=love&limit=3
{
"success": true,
"data": {
"results": 716,
"values": [
{
"id": "JDE3kGwBuLHMiUvv1itT",
"quote": "There is only one happiness in life, to love and be loved.",
"author": "George Sand",
"score": 6.7102118
},
{
"id": "JjE3kGwBuLHMiUvv1itT",
"quote": "Live through feeling and you will live through love. For feeling is the language of the soul, and feeling is truth.",
"author": "Matt Zotti",
"score": 6.2868223
},
{
"id": "NTE3kGwBuLHMiUvv1iFO",
"quote": "Genuine love should first be directed at oneself if we do not love ourselves, how can we love others?",
"author": "Dalai Lama",
"score": 5.236455
}
]
}
}
因此,正如您所见,我们决定将结果限制为3
,但有超过 713 条引言!
我们可以通过调用以下命令轻松获取接下来的三个报价:
curl localhost:3000/quotes?text=love&limit=3&page=2
{
"success": true,
"data": {
"results": 716,
"values": [
{
"id": "SsyHkGwBrOFNsaVmePwE",
"quote": "Forgiveness is choosing to love. It is the first skill of self-giving love.",
"author": "Mohandas Gandhi",
"score": 4.93597
},
{
"id": "rDE3kGwBuLHMiUvv1idS",
"quote": "Neither a lofty degree of intelligence nor imagination nor both together go to the making of genius. Love, love, love, that is the soul of genius.",
"author": "Wolfgang Amadeus Mozart",
"score": 4.7821507
},
{
"id": "TjE3kGwBuLHMiUvv1h9K",
"quote": "Speak low, if you speak love.",
"author": "William Shakespeare",
"score": 4.6697206
}
]
}
}
如果需要插入新的报价怎么办?只需调用/quotes/new
端点即可!
curl --request POST \
--url http://localhost:3000/quotes/new \
--header 'content-type: application/json' \
--data '{
"author": "Michele Riva",
"quote": "Using Docker and ElasticSearch is challenging, but totally worth it."
}'
响应将是:
{
"success": true,
"data": {
"id": "is2QkGwBrOFNsaVmFAi8",
"author": "Michele Riva",
"quote": "Using Docker and ElasticSearch is challenging, but totally worth it."
}
}
结论
Docker 使我们管理依赖项及其部署变得异常简单。从此以后,我们可以轻松地将应用程序托管在Heroku、AWS ECS、Google Cloud Container或任何其他基于 Docker 的服务上,而无需费力地设置服务器及其极其复杂的配置。
下一步是什么?
- 了解如何使用Kubernetes来扩展您的容器并协调更多 ElasticSearch 实例!
- 创建一个新端点,用于更新现有报价。错误可能会发生!
- 那么删除引言呢?该如何实现呢?
- 用标签保存您的名言(例如,关于爱情、健康、艺术的名言)会很棒...尝试更新您的
quotes
索引!
软件开发很有趣。有了 Docker、Node 和 ElasticSearch,就更棒了!
编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本。
插件:LogRocket,一个用于 Web 应用的 DVR
LogRocket是一款前端日志工具,可让您重放问题,就像它们发生在您自己的浏览器中一样。您无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 允许您重放会话以快速了解问题所在。它可与任何应用程序完美兼容,无论使用哪种框架,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的更多上下文。
除了记录 Redux 操作和状态之外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
免费试用。
使用 Node.js 和 ElasticSearch 在 Docker 上进行全文搜索一文首先出现在LogRocket 博客上。
文章来源:https://dev.to/bnevilleoneill/full-text-search-with-node-js-and-elasticsearch-on-docker-146k