10分钟教会你 Nest 结合 Elasticsearch 🚀

前言

本文将着重讲述如何在 Nestjs 中接入 Elasticsearch 搜索引擎,全文估计 10 分钟左右可以阅读完。

我们首先来了解下 Elasticsearch 是什么:

Elasticsearch

Elasticsearch 是一个基于Lucense的搜索引擎,用于存储、搜索和分析大量数据。它是基于 Apache Lucene 的搜索引擎,提供了一个简单易用的 RESTful API,可以进行实时的全文搜索、结构化搜索、分析和可视化数据。

对于ELasticsearch,我们还需要知道索引、类型、文档、映射、Query DSL查询、分片和副本等概念。

接下来我们学习下 Elasticsearch的核心原理------------倒排索引:

倒排索引

倒排索引(Inverted Index)是一种用于实现全文搜索的数据结构。它的原理是将文档中的每个单词都映射到出现该单词的文档列表。倒排索引的主要目的是加速文本搜索过程。

倒排索引的过程:

  1. 文档分词:会将索引的文档做分词处理,对于中文分词,我们一般需要安装 ik 分词器。

  2. 构建倒排索引:倒排索引由一个词典和多个倒排列表组成。词典保存了所有出现过的单词或词条,而每个倒排列表则记录了某个单词或词条在哪些文档中出现过。

  3. 排序和压缩:为了提高搜索效率和减少存储空间,倒排列表通常会进行排序和压缩操作。排序可以按照文档的编号或其他标识进行,以便更快地定位和访问相关文档。压缩可以采用各种算法,如前缀编码、差分编码等,减少存储空间的占用。

  4. 检索和匹配:当进行搜索时,将搜索词与倒排索引中的词典进行匹配,找到对应的倒排列表。然后根据倒排列表中的信息,可以快速定位到包含搜索词的文档,并计算相关性得分等。

接着我们首先定义初始的环境变量:

Nestjs 接入 Elasticsearch

js 复制代码
// config.service.ts
import * as dotenv from "dotenv";
import * as fs from "fs";

export class ConfigService {
  private readonly envConfig: { [key: string]: string };

  constructor(filePath: string) {
    this.envConfig = dotenv.parse(fs.readFileSync(filePath));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}
//config.module.ts
import { Module } from "@nestjs/common";
import { ConfigService } from "./config.service";

@Module({
  providers: [
    {
      provide: ConfigService,
      useValue: new ConfigService(".env"),
    },
  ],
  exports: [ConfigService],
})
export class ConfigModule {}

首先我们全局注册我们的配置服务,以便在 Elasticsearch 中注入当前环境。接下来我们配置 Elasticsearch 模块。

js 复制代码
pnpm install @elastic/elasticsearch@7.0.0
js 复制代码
// search.module.ts
import { Module, OnModuleInit } from "@nestjs/common";
import { SearchService } from "./search.service";
import { ElasticsearchModule } from "@nestjs/elasticsearch";
import { ConfigModule } from "../../config/config.module";
import { ConfigService } from "../../config/config.service";

@Module({
  imports: [
    ElasticsearchModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        // 节点地址
        node: configService.get("ELASTICSEARCH_NODE"),
        // 最大重试次数
        maxRetries: 10,
        requestTimeout: 60000,
        pingTimeout: 60000,
        // 是否自动发现集群中的节点
        sniffOnStart: true,
      }),
      inject: [ConfigService],
    }),
    ConfigModule,
  ],
  providers: [SearchService],
  exports: [SearchService],
})
export class SearchModule implements OnModuleInit {
  constructor(private searchService: SearchService) {}
  onModuleInit() {
    this.searchService.createIndex().then();
  }
}

当我们的服务启动的时候,需要模块初始化的时候执行特定的操作,所以我们这里实现了 OnModuleInit 接口,在onModuleInit方法中,通过调用 this.searchService.createIndex() 方法创建索引。这表示在SearchModule初始化完成后,会自动执行创建索引的操作。

接着我们需要在 searchService 中定义 createIndex 方法:

js 复制代码
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
import * as moviesJson from '../../../movies.json';
import { ConfigService } from '../../config/config.service';

interface MoviesJsonResponse {
    title: string;
    year: number;
    cast: string[];
    genres: string[];
}

@Injectable()
export class SearchService {
    constructor(private readonly esService: ElasticsearchService, private readonly configService: ConfigService) {}

    async createIndex() {
        const checkIndex = await this.esService.indices.exists({ index: this.configService.get('ELASTICSEARCH_INDEX') });
        if (checkIndex.statusCode === 404) {
            this.esService.indices.create(
                {
                    index: this.configService.get('ELASTICSEARCH_INDEX'),
                    body: {
                        settings: {
                            analysis: {
                                // 定义分析规则
                                analyzer: {
                                    autocomplete_analyzer: {
                                        tokenizer: 'autocomplete',
                                        filter: ['lowercase'],
                                    },
                                    autocomplete_search_analyzer: {
                                        tokenizer: 'keyword',
                                        filter: ['lowercase'],
                                    },
                                },
                                // 定义分词
                                tokenizer: {
                                    autocomplete: {
                                        type: 'edge_ngram',
                                        min_gram: 1,
                                        max_gram: 30,
                                        token_chars: ['letter', 'digit', 'whitespace'],
                                    },
                                },
                            },
                        },
                        // 定义映射
                        mappings: {
                            properties: {
                                title: {
                                    type: 'text',
                                    fields: {
                                        complete: {
                                            type: 'text',
                                            analyzer: 'autocomplete_analyzer',
                                            search_analyzer: 'autocomplete_search_analyzer',
                                        },
                                    },
                                },
                                year: { type: 'integer' },
                                genres: { type: 'nested' },
                                actors: { type: 'nested' },
                            },
                        },
                    },
                },
                (err) => {
                    if (err) {
                        console.error(err);
                    }
                },
            );
            const body = await this.parseAndPrepareData();
            this.esService.bulk(
                {
                    index: this.configService.get('ELASTICSEARCH_INDEX'),
                    body,
                },
                (err) => {
                    if (err) {
                        console.error(err);
                    }
                },
            );
        }
    }

    // 根据关键词搜索
    async search(search: string) {
        let results = [];
        const { body } = await this.esService.search({
            index: this.configService.get('ELASTICSEARCH_INDEX'),
            body: {
                size: 12,
                query: {
                    match: {
                        'title.complete': {
                            query: search,
                        },
                    },
                },
            },
        });
        const hits = body.hits.hits;
        hits.map((item) => {
            results.push(item._source);
        });

        return { results, total: body.hits.total.value };
    }

    // 解析测试用数据
    async parseAndPrepareData() {
        let body = [];
        const listMovies: MoviesJsonResponse[] = moviesJson;
        listMovies.map((item, index) => {
            let actorsData = [];
            item.cast.map((actor) => {
                const splited = actor.split(' ');
                actorsData.push({ firstName: splited[0], lastName: splited[1] });
            });

            body.push(
                { index: { _index: this.configService.get('ELASTICSEARCH_INDEX'), _id: index } },
                {
                    title: item.title,
                    year: item.year,
                    genres: item.genres.map((genre) => ({ genre })),
                    actors: actorsData,
                },
            );
        });
        return body;
    }
}

这里着重讲下 createIndex 方法用于创建索引。首先,它通过调用 esService.indices.exists 方法检查索引是否已存在。如果索引不存在(状态码为404),则调用 esService.indices.create 方法创建索引。创建索引时,指定了一些设置和映射,例如定义了一个名为autocomplete_analyzer的分析器和autocomplete_search_analyzer的搜索分析器,以及一个名为autocomplete的 tokenizer。然后,通过调用 parseAndPrepareData 方法解析和准备数据,并调用 esService.bulk 方法将数据批量插入索引。

接下来我们深入讲解下 DSL:

analyzer 分析器

js 复制代码
// 定义分析规则
analyzer: {
    autocomplete_analyzer: {
        tokenizer: 'autocomplete',
        filter: ['lowercase'],
    },
    autocomplete_search_analyzer: {
        tokenizer: 'keyword',
        filter: ['lowercase'],
    },
},

autocomplete_analyzer 将文本切割成不同长度的片段,以支持前缀匹配。此外,还应用了一个名为lowercase的过滤器(filter)。lowercase过滤器将所有词语转换为小写字母形式。 适用于索引中的title字段,用于创建支持前缀匹配的词语索引。autocomplete_search_analyzer 使用了名为 keyword 的分词器,该分词器将整个输入视为一个词语。这意味着输入不会被切割成片段,而是作为一个完整的词语进行匹配。主要用于将搜索关键字与索引中的词语进行匹配。

tokenizer 分词器

js 复制代码
// 定义分词
tokenizer: {
    autocomplete: {
        type: 'edge_ngram',
        min_gram: 1,
        max_gram: 30,
        token_chars: ['letter', 'digit', 'whitespace'],
    },
},

定义的分词器类型为edge_ngram。edge_ngram分词器会将输入的文本按照指定的规则进行切割,生成以不同长度的片段(n-gram)为单位的词语。具体来说,它会从文本的开头(edge)开始,按照指定的最小长度(min_gram)和最大长度(max_gram)生成不同长度的片段。例如,如果最小长度为1,最大长度为30,那么对于输入的文本"Hello World",会生成以下词语: - H - He - Hel - Hell - Hello - W - Wo - Wor - Worl - World 这样做的目的是为了支持前缀匹配的搜索。例如,当用户搜索"Hel"时,可以匹配到"Hello"这个词语。

mappings 映射

js 复制代码
// 定义映射
mappings: {
    properties: {
        title: {
            // 标题字段
            type: 'text',
            fields: {
                complete: {
                    type: 'text',
                    // 应用分析器
                    analyzer: 'autocomplete_analyzer',
                    search_analyzer: 'autocomplete_search_analyzer',
                },
            },
        },
        // 存储年份,整型
        year: { type: 'integer' },
        // 存储分类,数组类型
        genres: { type: 'nested' },
        // 存储演员信息,数组类型
        actors: { type: 'nested' },
    },
},

对应的 Document 的结构为:

通过定义映射,可以告诉 Elasticsearch 如何处理索引中的不同字段。每个字段的类型和属性决定了它们在搜索和分析过程中的行为和特性。这样可以确保索引中的数据按照预期的方式进行存储和检索。

接下来我们就可以愉快的写接口了,下面我将演示 MoveModule 业务模块的示例:

js 复制代码
// movie.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { MovieService } from './movie.service';

@Controller()
export class MovieController {
    constructor(public readonly movieService: MovieService) {}

    @Get('movies')
    async getMovies(@Query('search') search: string) {
        if (search !== undefined && search.length > 1) {
            return await this.movieService.findMovies(search);
        }
    }
}

// movie.service.ts
import { Injectable } from '@nestjs/common';
import { SearchService } from '../search/search.service';

@Injectable()
export class MovieService {
    constructor(readonly esService: SearchService) {}

    async findMovies(search: string = '') {
        return await this.esService.search(search);
    }
}

部署

js 复制代码
#App
APP_ENV=dev
GLOBAL_PREFIX=api
#Elastic
ELASTICSEARCH_NODE=http://xxx:9200
NODE_PORT=3000
ELASTICSEARCH_INDEX=movies-index
  1. 配置环境:配置环境我们只要在.env文件中定义后,在ConfigService中调用get方法获取环境变量即可。
js 复制代码
FROM node:13
WORKDIR /app/server
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000/tcp
CMD [ "node", "dist/main.js" ]
  1. 定义 Dockfile : FROM 获取基础镜像,并设置工作目录,拷贝package.json到当前目录中并安装依赖,然后拷贝目录下所有文件,最后打包构建,使用 EXPOSE 指令指定应用程序运行的端口号,最后我们执行 node 的指令启动服务。

  2. 运行docker :运行 docker build -t your_image_name . 命令来构建Docker镜像。将 your_image_name 替换为适当的镜像名称。 构建完成后可以使用 docker images 命令查看所有可用的镜像列表,确认新构建的镜像已添加。 运行 docker run -p host_port:container_port your_image_name 命令来运行容器。将 host_port 替换为主机上要映射的端口号,将 container_port 替换为Docker容器中应用程序运行的端口号。应用程序此时在Docker容器中运行,通过端口号可以访问。

总结

通过本文的学习,我们深入了解了 Elasticsearch 的基础概念和倒排索引的原理。同时,我们学习了如何在 Nestjs 中接入 Elasticsearch 并进行部署。使用 Elasticsearch,我们能够轻松实现对海量数据的快速搜索。最后为了更好的管理数据,我们一般会使用可视化的工具来检索管理数据,比如 kibana、Grafana等。

觉得不错可以点赞、关注、收藏,感谢你的支持!🥰

相关推荐
卸任4 小时前
Vibe coding工具使用心得
前端·ai编程
brien95 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance5 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M5 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写5 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李5 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易6 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马6 小时前
前沿视界
前端
kyriewen6 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易6 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员