NestJS最佳实践--#8 ElasticSearch 和 MySQL 实现全文搜索
文章发布在专栏NestJS最佳实践中
什么是全文搜索
简介: 全文检索技术被广泛的应用于搜索引擎,查询检索等领域。我们在网络上的大部分搜索服务都用到了全文检索技术。 对于数据量大、数据结构不固定的数据可采用全文检索方式搜索,比如百度、Google等搜索引擎、论坛站内搜索、电商网站站内搜索等。
学习目标
- 在NestJS中使用ElasticSearch
- 实现Post的全文搜索
- ElasticSearch 全文搜索
- MySQL like 全文搜索
- MySQL全文索引函数 match() against() 全文搜索
公共代码
搜索方式切换
因为我们会使用三种不同的方式来实现全文搜索,所以最好有一个配置用于切换, 因此我们在dev.yml 的 APP 下添加 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
es01和es02是ElasticSearch集群中的两个节点实例,其中es01是主节点。
Kibana 是一个免费且开放的用户界面,能够让您对Elasticsearch 数据进行可视化,并让您在Elastic Stack 中进行导航
xpack.security.enabled=false 因为是开发环境,为了简单起见就把安全模块禁用,这样就不要设置密码了。
启动
执行启动命令
docker-compose up -d
如果能在控制台和 Docker Desktop能看到下图内容就证明启动成功了 接下来我们要验证下启动的ElasticSearch和Kibana是否可用
验证
- 浏览器输入:http://localhost:9200/ 能否看到以下输出
- 浏览器输入:http://localhost:5601/ 能否看到以下页面
如果上面两步结果都正确,那么恭喜你已经成功的使用 Docker Compose 启动 ElasticSearch 了。
ElasticModule
ElasticSearch安装并启动成功后,我们要在项目中创建一个ElasticModule,用于访问我们启动的ElasticSearch。
- 安装依赖
pnpm install @nestjs/elasticsearch @elastic/elasticsearch
@elastic/elasticsearch: Elasticsearch的官方Node.js客户端。
@nestjs/elasticsearch: Nest的Elasticsearch模块, 基于官方的@elastic/aelasticsearch包封装而来。
- 更新dev.yml
ELASTIC:
node: 'http://localhost:9200'
maxRetries: 10
requestTimeout: 60000
pingTimeout: 60000
sniffOnStart: true
- 创建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.yml中 ELASTIC 配置
-
将 ElasticModule设为了全局模块,并导出了 ElasticsearchModule, 这样我们就可以在全局使用@nestjs/elasticsearch包中的 ElasticsearchService了
SearchService 是我们接下来要实现的全文搜索服务
- 更新 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')
, 在文章的后面我们会分别添加对like
和against
的判断。
验证
创建文章
Kibana控制台验证
打开网址:http://localhost:5601/app/dev_tools#/console
MySQL against 实现全文搜索
本小节介绍如何通过全文索引函数 match() against() 进行全文检索
创建全文索引
要想在MySQL中实现全文搜索,前提条件是创建全文索引,也就是针对搜索条件如文章的title、body、summary和分类的name字段依次添加全文索引。
typeorm中可以在Entity中某个字段上添加以下装饰器来创建全文索引
@Index({ fulltext: true })
下面依次在 post.entity.ts和catetory.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, 此时去查看数据库中post和category两张表的索引中会出现相应的全文索引
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”, 重启服务
可以看到 我们搜索 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”, 重启服务
同样是查询lodash v2, like查询却只返回一条结果。
感兴趣的同学,可以去查询下 against和like的区别,由于篇幅限制,这里就不再赘述了。
📢
against
不支持向前的模糊匹配,比如一个文章关联的其中一个分类名称为我是分类1
,你搜索时传入我是*
是可以匹配到文章数据的,而传入*分类1
是匹配不到的,但是性能及佳。而
like
可以实现任何自动的模糊匹配,但是性能比较一般
总结
以上就是我们实习全文搜索的三种常见途径了,但有时选择项过多也是一种烦恼,到底应该如何抉择呢?
我一般会这样使用,在中小型应用中,不考虑向前模糊匹配的话一般使用against,考虑到全文模糊匹配的话就使用like,大型应用可以使用Elastic等专业的全文搜索工具
转载自:https://juejin.cn/post/7216213764776935481