likes
comments
collection
share

NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索

作者站长头像
站长
· 阅读数 62

文章发布在专栏NestJS最佳实践

代码地址:github.com/slashspaces…

什么是全文搜索

简介:  全文检索技术被广泛的应用于搜索引擎,查询检索等领域。我们在网络上的大部分搜索服务都用到了全文检索技术。 对于数据量大、数据结构不固定的数据可采用全文检索方式搜索,比如百度、Google等搜索引擎、论坛站内搜索、电商网站站内搜索等。

学习目标

  • 在NestJS中使用ElasticSearch
  • 实现Post的全文搜索
    • ElasticSearch 全文搜索
    • MySQL like 全文搜索
    • MySQL全文索引函数 match() against() 全文搜索

公共代码

搜索方式切换

因为我们会使用三种不同的方式来实现全文搜索,所以最好有一个配置用于切换, 因此我们在dev.ymlAPP 下添加 searchType 配置

APP:
  searchType: "elastic" # 全文搜索: like, against, elastic

通过以下方式获取配置文件中定义的searchType后执行相对应的逻辑

const { searchType = 'against' } = this.configService.get('APP');

搜索条件设置

因为我们实现对Post的全文搜索,所以要设置QueryPostDto, 其中search字段作为搜索条件可以是 post的 title, body, summary 也可以是 Category的name

@DtoValidation({ groups: ['query'] })
export class QueryPostDto extends PaginationDto {
    @MaxLength(100, {
        always: true,
        message: '搜索字符串长度不得超过$constraint1',
    })
    @IsOptional({ always: true })
    search?: string;
}

ElasticSearch实现全文搜索

ElasticSearch安装

在专栏的#2章节中,我们已经开始使用Docker Compose了,同时我们也在ElasticSearch的官方文档中找到了通过Docker Compose安装的示例,所以本节我们依然使用 Docker Compose 来安装ElasticSearch

docker-compose.yml 配置

# docker-compose 多容器部署
# 启动命令 docker compose up --detach 后台启动
version: '3.1'

services:
  # ElasticSearch 集群
  es01:
    image: elasticsearch:8.6.1 # image: docker.elastic.co/elasticsearch/elasticsearch:8.6.1
    container_name: es01
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es02
      - cluster.initial_master_nodes=es01,es02
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - .volumes/es01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
  es02:
    image: elasticsearch:8.6.1 # image: docker.elastic.co/elasticsearch/elasticsearch:8.6.1
    container_name: es02
    environment:
      - node.name=es02
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01
      - cluster.initial_master_nodes=es01,es02
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - .volumes/es02:/usr/share/elasticsearch/data
  kibana:
    image: kibana:8.6.1
    container_name: kibana
    ports:
      - 5601:5601
    environment:
      - SERVERNAME=kibana
      - I18N_LOCALE=zh-CN
      - ELASTICSEARCH_HOSTS=http://es01:9200
    depends_on:
        - es01
    volumes:
      - .volumes/kibana:/usr/share/kibana/data

es01es02是ElasticSearch集群中的两个节点实例,其中es01是主节点。

Kibana 是一个免费且开放的用户界面,能够让您对Elasticsearch 数据进行可视化,并让您在Elastic Stack 中进行导航

xpack.security.enabled=false 因为是开发环境,为了简单起见就把安全模块禁用,这样就不要设置密码了。

启动

执行启动命令

docker-compose up -d

如果能在控制台Docker Desktop能看到下图内容就证明启动成功了 NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索 接下来我们要验证下启动的ElasticSearchKibana是否可用

验证

如果上面两步结果都正确,那么恭喜你已经成功的使用 Docker Compose 启动 ElasticSearch 了。

ElasticModule

ElasticSearch安装并启动成功后,我们要在项目中创建一个ElasticModule,用于访问我们启动的ElasticSearch。

  1. 安装依赖
pnpm install @nestjs/elasticsearch @elastic/elasticsearch

@elastic/elasticsearch: Elasticsearch的官方Node.js客户端。

@nestjs/elasticsearch: Nest的Elasticsearch模块, 基于官方的@elastic/aelasticsearch包封装而来。

  1. 更新dev.yml
ELASTIC:
  node: 'http://localhost:9200'
  maxRetries: 10
  requestTimeout: 60000
  pingTimeout: 60000
  sniffOnStart: true
  1. 创建ElasticModule
@Module({})
export class ElasticModule {
    static forRoot(): DynamicModule {
        return {
            global: true, // 全局模块
            module: ElasticModule,
            imports: [
                ElasticsearchModule.registerAsync({
                    imports: [ConfigModule],
                    inject: [ConfigService],
                    useFactory(configService: ConfigService): ElasticsearchModuleOptions {
                        const elasticConfig = configService.get('ELASTIC');
                        return { ...elasticConfig };
                    },
                }),
            ],
            providers: [SearchService],
            exports: [ElasticsearchModule, SearchService],
        };
    }
}
  • 使用 @nestjs/elasticsearch (内部使用 @elastic/elasticsearch) 连接ElasticSearch

  • 导入ConfigModule, 目的是为了使用 ConfigService 获取 dev.ymlELASTIC 配置

  • ElasticModule设为了全局模块,并导出了 ElasticsearchModule, 这样我们就可以在全局使用@nestjs/elasticsearch包中的 ElasticsearchService

SearchService 是我们接下来要实现的全文搜索服务

  1. 更新 app.module.ts
@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true, // 全局模块
            ignoreEnvFile: true, // 忽略.env相关配置文件
            load: [getConfig], // 读取自定义文件
        }),
        DataBaseModule.forRoot(),
        ElasticModule.forRoot(),
        BusinessModule,
    ],
    // ...
})

SearchService

接下来我们要实现全文搜索服务

  • search方法通过传入的字符串对posts索引下的'title', 'body', 'summary'以及文章关联的分类名进行搜索,最后返回搜索出来的文章列表
  • create方法用于在新文章创建保存到数据库后是同时把该文章的id等字段以及其关联的分类的名称存储到ElasticSearch的posts索引中
  • update方法用于在更新文章时同步更新ElasticSearch的posts索引中对应id的文章的数据
  • delete方法用于在删除文章时同时删除ElasticSearch的posts索引中对应id的文章(注意:在PostService中我们在软删除时也需要把该数据从ElasticSearch中删除,但是在恢复时我们可以把它也恢复到ElasticSearch中)
@Injectable()
export class SearchService {
    // ElasticSearch 索引
    index = 'posts';

    @Optional()
    @Inject()
    private esService: ElasticsearchService;

    /**
     * 通过关键字搜索文章列表
     * @param 搜索条件{@link Post.title}, {@link Post.body}, {@link Category.nmae}
     * @returns 搜索结果{@link Post[]}
     */
    async search(text: string): Promise<Post[]> {
        const { hits } = await this.esService.search<Post>({
            index: this.index,
            query: {
                multi_match: {
                    query: text,
                    fields: ['title', 'body', 'summary', 'categories'],
                },
            },
        });
        return hits.hits.map((item) => item._source);
    }

    /**
     * 当创建一篇文章时创建它的es索引
     * @param Post
     */
    async create(post: Post): Promise<WriteResponseBase> {
        return this.esService.index<PostSearchBody>({
            index: this.index,
            document: {
                ...pick(instanceToPlain(post), ['id', 'title', 'body', 'summary']),
                categories: (post.categories?.map((item) => item.name) ?? []).join(','),
            },
        });
    }

    /**
     * 更新文章时更新它的es字段
     * @param Post
     */
    async update(post: Post) {
        const newBody: PostSearchBody = {
            ...pick(post, ['id', 'title', 'body', 'summary']),
            categories: (post.categories?.map((item) => item.name) ?? []).join(','),
        };

        /**
         * 注意分号 ;
         * "ctx._source.id='dfe557a0-f3c0-4003-bcaa-90d31d158e20';
         * ctx._source.title='lodash v11';
         * ctx._source.body='lodash v5';
         * ctx._source.summary='nodejs';
         * ctx._source.categories='react-router';"
         */
        const script = Object.entries(newBody).reduce(
            (result, [key, value]) => `${result} ctx._source.${key}='${value}';`,
            '',
        );

        return this.esService.updateByQuery({
            index: this.index,
            query: { match: { id: post.id } },
            script,
        });
    }

    /**
     * 删除文章的同时在es中删除这篇文章
     * @param postId {@link Post#id}
     */
    async remove(postId: string) {
        return this.esService.deleteByQuery({
            index: this.index,
            query: { match: { id: postId } },
        });
    }
}

PostService

完成SearchService后,我们就要在Post的增删改查时调用SearchService的增删改查了。

@Injectable()
export class PostService {
    constructor(
        @InjectRepository(Post)
        private postsRepository: Repository<Post>,
        private categoryRepository: CategoryRepository,

        private configService: ConfigService,
        // 可选的
        protected searchService?: SearchService,
    ) {}

    async search({ search }: QueryPostDto) {
        const { searchType = 'against' } = this.configService.get('APP');
        if (searchType === 'elastic') {
            // ElasticSearch 全文搜索
            const result = await this.searchService.search(search);
            Logger.info(result);
            const ids = result.map((item) => item.id);
            return this.postsRepository.find({ where: { id: In(ids) } });
        }
    }

    async create(createPostDto: CreatePostDto) {
        const categories = await this.categoryRepository.findBy({
            id: In(createPostDto.categories),
        });
        const post = await this.postsRepository.save({ ...createPostDto, categories });

        if (this.searchService) {
            await this.searchService.create(post);
        }
        return post;
    }

    async update({ id, ...updatePostDto }: UpdatePostDto) {
        const current = await this.findOne(id);

        // 更新关联表数据
        await this.postsRepository
            .createQueryBuilder('post')
            .relation(Post, 'categories')
            .of(current)
            .addAndRemove(updatePostDto.categories, current.categories ?? []);

        // 更新文章
        await this.postsRepository.update({ id }, omit(updatePostDto, ['id', 'categories']));

        // 更新后的文章信息
        const post = await this.findOne(id);

        if (this.searchService) {
            this.searchService.update(post);
        }

        return post;
    }

    async remove({ ids, soft }: DeleteDto) {
        const posts = await this.postsRepository.find({
            where: { id: In(ids) },
            withDeleted: false, // 过滤掉已删除的
        });

        if (this.searchService) {
            for (const id of ids) {
                await this.searchService.remove(id);
            }
        }

        if (soft) return this.postsRepository.softRemove(posts);
        return this.postsRepository.remove(posts);
    }

    async restore({ ids }: RestoreDto) {
        const posts = await this.postsRepository.find({
            where: { id: In(ids) },
            withDeleted: true,
            relations: ['categories'],
        });
        // 过滤掉不在回收站中的数据
        const deletedPosts = posts.filter((post) => !isNil(post.deletedAt));
        const deletedIds = deletedPosts.map((post) => post.id);
        if (isEmpty(deletedIds)) return;

        if (this.searchService) {
            for (const deletedPost of deletedPosts) {
                await this.searchService.create(deletedPost);
            }
        }

        this.postsRepository.restore(deletedIds);
    }
    
    // ...
}

search 方法中 我们只判断了一个分支 if (searchType === 'elastic'), 在文章的后面我们会分别添加对 likeagainst的判断。

验证

创建文章

NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索

Kibana控制台验证

打开网址:http://localhost:5601/app/dev_tools#/console NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索

MySQL against 实现全文搜索

本小节介绍如何通过全文索引函数 match() against() 进行全文检索

创建全文索引

要想在MySQL中实现全文搜索,前提条件是创建全文索引,也就是针对搜索条件如文章的titlebodysummary和分类的name字段依次添加全文索引。

typeorm中可以在Entity中某个字段上添加以下装饰器来创建全文索引

@Index({ fulltext: true })

下面依次在 post.entity.tscatetory.entity.ts中的相应字段上添加全文索引

@Exclude()
@Entity('post')
export class Post {
    // ...

    @Expose()
    @Column({ comment: '文章标题' })
    @Index({ fulltext: true })
    title!: string;

    @Expose({ groups: ['post-detail'] })
    @Column({ comment: '文章内容', type: 'longtext' })
    @Index({ fulltext: true })
    body!: string;

    @Expose()
    @Column({ comment: '文章描述', nullable: true })
    @Index({ fulltext: true })
    summary?: string;
    
    // ...
}

@Exclude()
@Tree('materialized-path')
@Entity('category')
export class Category {
    // ...

    @Expose()
    @Column({ comment: '分类名称' })
    @Index({ fulltext: true })
    name!: string;
    
    // ...
}

完成上述步骤后,如果你的typeorm设置了synchronize为true, 此时去查看数据库中postcategory两张表的索引中会出现相应的全文索引

NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索

match() against()语法

详细介绍可以参考阿里云SQL手册中的一篇文章:通过全文索引查询 这里我们简单介绍下 match() against() 的语法

语法

SELECT * FROM `table_name` WHERE match (column_name[ , ... ]) against('term')

参数说明

  • table_name:检索的数据表。

  • column_name:检索的数据列。如果对多个列进行检索,列名之间用英文逗号(,)隔开。

  • term:检索的关键词。关键词查询时支持如下逻辑操作符:

    • AND:检索出同时匹配所有关键词的内容。
    • OR:检索出匹配其中一个关键词的内容。
    • NOT:检索出只匹配逻辑操作符左侧关键词的内容。

更新 PostService的 search 方法

    async search({ search }: QueryPostDto) {
        const { searchType = 'against' } = this.configService.get('APP');
        if (searchType === 'elastic') {
            // ElasticSearch 全文搜索
            const result = await this.searchService.search(search);
            Logger.info(result);
            const ids = result.map((item) => item.id);
            return this.postsRepository.find({ where: { id: In(ids) } });
        }
        const qb = this.postsRepository
            .createQueryBuilder('article')
            .leftJoinAndSelect('article.categories', 'category');
        if (searchType === 'against') {
            qb.andWhere('MATCH(title) AGAINST (:title)', { title: `${search}*` })
                .orWhere('MATCH(body) AGAINST (:body)', { body: `${search}*` })
                .orWhere('MATCH(summary) AGAINST (:summary)', {
                    summary: `${search}*`,
                })
                .orWhere('MATCH(category.name) AGAINST (:categoryName)', {
                    categoryName: `${search}*`,
                });
        } 
        return qb.getMany();
    }

验证

📢 将 dev.yml配置文件中 APP下的 searchType 改为 “against”, 重启服务

NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索 可以看到 我们搜索 lodash v2, 查出两条结果,这里要留意一下,待会要和 like查询结果做对比

MySQL like 实现全文搜索

长写sql的同学对like的语法肯定不陌生,这里我就不再赘述了。

更新PostSearch的search方法

    async search({ search }: QueryPostDto) {
        const { searchType = 'against' } = this.configService.get('APP');
        if (searchType === 'elastic') {
            // ElasticSearch 全文搜索
            const result = await this.searchService.search(search);
            Logger.info(result);
            const ids = result.map((item) => item.id);
            return this.postsRepository.find({ where: { id: In(ids) } });
        }
        const qb = this.postsRepository
            .createQueryBuilder('article')
            .leftJoinAndSelect('article.categories', 'category');
        if (searchType === 'against') {
            qb.andWhere('MATCH(title) AGAINST (:title)', { title: `${search}*` })
                .orWhere('MATCH(body) AGAINST (:body)', { body: `${search}*` })
                .orWhere('MATCH(summary) AGAINST (:summary)', {
                    summary: `${search}*`,
                })
                .orWhere('MATCH(category.name) AGAINST (:categoryName)', {
                    categoryName: `${search}*`,
                });
        } else if (searchType === 'like') {
            qb.andWhere('title LIKE :title', { title: `%${search}%` })
                .orWhere('body LIKE :body', { body: `%${search}%` })
                .orWhere('summary LIKE :summary', { summary: `%${search}%` })
                .orWhere('category.name LIKE :categoryName', { categoryName: `${search}` });
        }
        return qb.getMany();
    }

验证

📢 将 dev.yml配置文件中 APP下的 searchType 改为 “like”, 重启服务

NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索 同样是查询lodash v2, like查询却只返回一条结果。

感兴趣的同学,可以去查询下 againstlike的区别,由于篇幅限制,这里就不再赘述了。

📢 against不支持向前的模糊匹配,比如一个文章关联的其中一个分类名称为我是分类1,你搜索时传入我是*是可以匹配到文章数据的,而传入*分类1是匹配不到的,但是性能及佳

like可以实现任何自动的模糊匹配,但是性能比较一般

总结

以上就是我们实习全文搜索的三种常见途径了,但有时选择项过多也是一种烦恼,到底应该如何抉择呢?

我一般会这样使用,在中小型应用中,不考虑向前模糊匹配的话一般使用against,考虑到全文模糊匹配的话就使用like,大型应用可以使用Elastic等专业的全文搜索工具