Elasticsearch(看这一篇就够了)

在这里插入图片描述

Elasticsearch

介绍

在这里插入图片描述
Elasticsearch是一个全文检索服务器

全文检索是一种非结构化数据的搜索方式

  • 结构化数据:指具有固定格式固定长度的数据,如数据库中的字段。

  • 非结构化数据:指格式和长度不固定的数据,如电商网站的商品详情。

结构化数据一般存入数据库,使用sql语句即可快速查询。但由于非结构化数据的数据量大且格式不固定,我们需要采用全文检索的方式进行搜索。全文检索通过建立倒排索引加快搜索效率。

正排索引和倒排索引

索引

将数据中的一部分信息提取出来,重新组织成一定的数据结构,我们可以根据该结构进行快速搜索,这样的结构称之为索引。

索引即目录,例如字典会将字的拼音提取出来做成目录,通过目录即可快速找到字的位置。

索引分为正排索引倒排索引

正排索引(正向索引)
将文档id建立为索引,通过id快速可以快速查找数据。如数据库中的主键就会创建正排索引。
在这里插入图片描述

倒排索引(反向索引)
非结构化数据中我们往往会根据关键词查询数据。此时我们将数据中的关键词建立为索引,指向文档数据,这样的索引称为倒排索引。
在这里插入图片描述

Elasticsearch安装

安装ES服务

准备工作
1.准备一台搭载有CentOS7系统的虚拟机,使用XShell连接虚拟机

2.关闭防火墙,方便访问ES

#关闭防火墙:
systemctl stop firewalld.service


#禁止防火墙自启动:
systemctl disable firewalld.service

3.配置最大可创建文件数大小

#打开系统文件:
vim /etc/sysctl.conf


#添加以下配置:
vm.max_map_count=655360


#配置生效:
sysctl -p
  1. 由于ES不能以root用户运行,我们需要创建一个非root用户,此处创建一个名为es的用户:
#创建用户:
useradd es

安装服务

  1. 使用rz命令将linux版的ES上传至虚拟机

  2. 解压ES

#解压:
tar -zxvf elasticsearch-8.10.4-linux-x86_64.tar.gz


#重命名:
mv elasticsearch-8.10.4 elasticsearch1


#移动文件夹:
mv elasticsearch1 /usr/local/


#es用户取得该文件夹权限:
chown -R es:es /usr/local/elasticsearch1
  1. 启动ES服务:
#切换为es用户:
su es


#进入ES安装文件夹:
cd /usr/local/elasticsearch1/bin/


#启动ES服务:
./elasticsearch 
  1. 当启动成功,可以看到类似以下的日志输出。首次启动Elasticsearch,默认会启用安全配置功能,启用身份认证和授权,内置超级用户elastic,并生成默认密码,此时要注意保存,否则之后启动不会再显示。
    在这里插入图片描述
# 重置默认密码:
cd /usr/local/elasticsearch1/bin/
./elasticsearch-reset-password -u elastic


# 自定义密码:
cd /usr/local/elasticsearch1/bin/
./elasticsearch-reset-password --username elastic -i
  1. 连接ES,查询ES服务是否启动成功
# 参数 --cacert指定了证书
curl --cacert /usr/local/elasticsearch1/config/certs/http_ca.crt -u elastic https://localhost:9200

安装kibana

Kibana是一款开源的数据分析和可视化平台,设计用于和Elasticsearch协作。我们可以使用Kibana对Elasticsearch索引中的数据进行搜索、查看、交互操作。

  1. 使用rz工具将Kibana压缩文件上传到Linux虚拟机

  2. 解压

tar -zxvf kibana-8.10.4-linux-x86_64.tar.gz -C /usr/local/
  1. 修改配置
# 进入Kibana解压路径
cd /usr/local/kibana-8.10.4/config

# 修改配置文件
vim kibana.yml

# 加入以下内容
# 主机IP,服务名
server.host: "虚拟机IP"
server.name: "kibana"
  1. 启动:

kibana不能以root用户运行,我们给es用户设置kibana目录的权限,并使用es用户运行kibana

# 给es用户设置kibana目录权限
chown -R es:es /usr/local/kibana-8.10.4/


# 切换为es用户
su es


# 启动kibana
cd /usr/local/kibana-8.10.4/bin/
./kibana
  1. 访问kibana:http://虚拟机IP:5601
    首次访问Kibana管理台会提示输入ES生成的token秘钥,可以在ES首次启动日志中找。

在这里插入图片描述
如果token已失效或不正确,你也可以重新生成token。

# 进入es安装目录
cd /usr/local/elasticsearch1/bin

# 重新生成kibana的token
.elasticsearch-create-enrollment-token --scope kibana

紧接着输入登录账号 elastic,密码也同样是从ES首次启动日志中找。

  1. 点击Management => Index Management可以查看es索引信息。

索引操作

创建索引

Elasticsearch是使用RESTful风格的http请求访问操作的,请求参数和返回值都是Json格式的,我们可以使用kibana发送http请求操作ES。

创建没有结构的索引
路径:ip地址:端口号/索引名

注:在kibana中所有的请求都会省略ip地址:端口号,之后的路径我们省略写ip地址:端口号

格式:

PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...}
  }
}

基本语法:

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:mapping映射

格式

为索引添加结构

POST /索引名/_mapping
{
    "properties":{
        "域名1":{
            "type":域的类型,
            "store":是否存储,
            "index":是否创建索引,
      "analyzer":分词器
       },
       "域名2":{ 
            ...
        }
    }
}
PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...}
  }
}

查询索引库

基本语法

  • 请求方式:GET
  • 请求路径:/索引库名
  • 请求参数:无

格式

GET /索引库名

修改索引库

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping

虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。

语法说明

PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}

删除索引库

语法

  • 请求方式:DELETE

  • 请求路径:/索引库名

  • 请求参数:无

格式

DELETE /索引库名

Elasticsearch常用操作

文档操作

新增文档
POST /索引/_doc/[id值]
{
    "field名":field值
}

POST /索引库名/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
    // ...
}

示例:

POST /jjy/_doc/1
{
    "info": "jjy最牛啦",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

注:id值不写时自动生成文档id,id和已有id重复时修改文档

查询文档
GET /索引/_doc/id值

示例:

GET /jjy/_doc/1
删除文档
DELETE /索引/_doc/id值

示例:

DELETE /jjy/_doc/1
根据id批量查询文档
GET /索引/_mget
{
  "docs":[
     {"_id":id值},
     {"_id":id值}
   ] 
}

示例:

GET /jjy/_mget
{
  "docs":[
     {"_id":1},
     {"_id":2}
   ] 
}
查询所有文档
GET /索引/_search
{
   "query": {
     "match_all": {}
   }
}

示例:

GET /jjy/_search
{
   "query": {
     "match_all": {}
   }
}
修改文档部分字段
POST /索引/_update/1/
{ 
  "doc":{ 
    域名:值
    } 
}

示例:

POST /jjy/_update/id值/
{ 
  "doc":{ 
    info:"jjy好厉害哦"
    } 
}

注:

Elasticsearch执行删除操作时,ES先标记文档为deleted状态,而不是直接物理删除。当ES存储空间不足或工作空闲时,才会执行物理删除操作。

Elasticsearch执行修改操作时,ES不会真的修改Document中的数据,而是标记ES中原有的文档为deleted状态,再创建一个新的文档来存储数据。

域的属性

index

该域是否创建索引。只有值设置为true,才能根据该域的关键词查询文档。

// 根据关键词查询文档
GET /索引名/_search
{
    "query":{
    "term":{ 
            搜索字段: 关键字
        } 
   }
}

type

域的类型

核心类型具体类型
字符串类型text
整数类型long, integer, short, byte
浮点类型double, float
日期类型date
布尔类型boolean
数组类型array
对象类型object
不分词的字符串keyword

store
是否单独存储。如果设置为true,则该域能够单独查询。

// 单独查询某个域:
GET /索引名/_search
{
 "stored_fields": ["域名"]
}

分词器

默认分词器

ES文档的数据拆分成一个个有完整含义的关键词,并将关键词与文档对应,这样就可以通过关键词查询文档。要想正确的分词,需要选择合适的分词器。

standard analyzer:Elasticsearch默认分词器,根据空格和标点符号对英文进行分词,会进行单词的大小写转换。

  • 查看分词效果
GET /_analyze
{
    "text":测试语句, 
    "analyzer":分词器
}

IK分词器

IKAnalyzer是一个开源的,基于java语言开发的轻量级的中文分词工具包。提供了两种分词算法:

  • ik_smart:最少切分

  • ik_max_word:最细粒度划分

安装IK分词器

  1. 关闭es服务

  2. 使用rz命令将ik分词器上传至虚拟机

注:ik分词器的版本要和es版本保持一致。

  1. 解压ik分词器到elasticsearch的plugins目录下

unzip elasticsearch-analysis-ik-8.10.4.zip -d /usr/local/elasticsearch1/plugins/analysis-ik

  1. 启动ES服务
su es


#进入ES安装文件夹:
cd /usr/local/elasticsearch1/bin/


#启动ES服务:
./elasticsearch

测试分词器效果

GET /_analyze
{
    "text":"测试语句", 
    "analyzer":"ik_smart/ik_max_word"
}

IK分词器词典
IK分词器根据词典进行分词,词典文件在IK分词器的config目录中。

  • main.dic:IK中内置的词典。记录了IK统计的所有中文单词。

  • IKAnalyzer.cfg.xml:用于配置自定义词库。

<properties>
    <comment>IK Analyzer 扩展配置</comment>
    <!--用户可以在这里配置自己的扩展字典 -->
    <entry key="ext_dict">ext_dict.dic</entry>
     <!--用户可以在这里配置自己的扩展停止词字典-->
    <entry key="ext_stopwords">ext_stopwords.dic</entry>
    <!--用户可以在这里配置远程扩展字典 -->
    <!-- <entry key="remote_ext_dict">words_location</entry> -->
    <!--用户可以在这里配置远程扩展停止词字典-->
    <!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

拼音分词器

拼音分词器可以将中文分成对应的全拼,全拼首字母等。

安装拼音分词器

  1. 关闭es服务

  2. 使用rz命令将拼音分词器上传至虚拟机

注:拼音分词器的版本要和es版本保持一致。

解压pinyin分词器到elasticsearch的plugins目录下

unzip elasticsearch-analysis-pinyin-8.10.4 -d /usr/local/elasticsearch1/plugins/analysis-pinyin
启动ES服务

su es

#进入ES安装文件夹:
cd /usr/local/elasticsearch1/bin/

#启动ES服务:
./elasticsearch

测试分词效果

GET /_analyze
{
    "text":测试语句, 
    "analyzer":"pinyin"
}

自定义分词器

真实开发中我们往往需要对一段内容既进行文字分词,又进行拼音分词,此时我们需要自定义ik+pinyin分词器。

创建自定义分词器
在创建索引时自定义分词器

PUT /索引名
{
  "settings" : {
    "analysis" : {
      "analyzer" : {
        "ik_pinyin" : { //自定义分词器名
          "tokenizer":"ik_max_word", // 基本分词器
          "filter":"pinyin_filter" // 配置分词器过滤
         }
       },
      "filter" : { // 分词器过滤时配置另一个分词器,相当于同时使用两个分词器
        "pinyin_filter" : { 
          "type" : "pinyin", // 另一个分词器
          // 拼音分词器的配置
          "keep_separate_first_letter" : false, // 是否分词每个字的首字母
          "keep_full_pinyin" : true, // 是否分词全拼
          "keep_original" : true, // 是否保留原始输入
          "remove_duplicated_term" : true // 是否删除重复项
         }
       }
     }
   },
  "mappings":{
    "properties":{
      "域名1":{
        "type":域的类型,
        "store":是否单独存储,
        "index":是否创建索引,
             "analyzer":分词器
       },
      "域名2":{ 
        ...
       }
     }
   }
}

测试自定义分词器

GET /索引/_analyze
{
 "text": "你好程序员",
 "analyzer": "ik_pinyin"
}

Elasticsearch搜索文档

添加一些文档数据

PUT /students
{
  "mappings":{
    "properties":{
      "id": {
            "type": "integer",
        "index": true
       },
         "name": {
            "type": "text",
            "store": true,
        "index": true,
            "analyzer": "ik_smart"
         },
      "info": {
            "type": "text",
            "store": true,
        "index": true,
            "analyzer": "ik_smart"
         }
     }
   }
}


POST /students/_doc/
{
 "id":1,
 "name":"程序员",
 "info":"I love baizhan"
}


POST /students/_doc/
{
 "id":2,
 "name":"美羊羊",
 "info":"美羊羊是羊村最漂亮的人"
}


POST /students/_doc/
{
 "id":3,
 "name":"懒羊羊",
 "info":"懒羊羊的成绩不是很好"
}


POST /students/_doc/
{
 "id":4,
 "name":"小灰灰",
 "info":"小灰灰的年纪比较小"
}


POST /students/_doc/
{
 "id":5,
 "name":"沸羊羊",
 "info":"沸羊羊喜欢美羊羊"
}


POST /students/_doc/
{
 "id":6,
 "name":"灰太狼",
 "info":"灰太狼是小灰灰的父亲,每次都会说我一定会回来的"
}

搜索方式

match_all:查询所有文档

{
    "query":{
    "match_all":{}
   }
}

match:全文检索。将查询条件分词后再进行搜索。

{
    "query":{
    "match":{
      搜索字段:搜索条件
     }
   }
}

注:在搜索时关键词有可能会输入错误,ES搜索提供了自动纠错功能,即ES的模糊查询。使用match方式可以实现模糊查询。模糊查询对中文的支持效果一般,我们使用英文数据测试模糊查询。

{
    "query": {
        "match": {
            "域名": {
            "query": 搜索条件,
            "fuzziness": 最多错误字符数,不能超过2
            }
        }
    }
}

range:范围搜索。对数字类型的字段进行范围搜索

{
    "query":{
    "range":{
      搜索字段:{ 
        "gte":最小值,
        "lte":最大值
       } 
     }
   }
}

gt/lt:大于/小于
gte/lte:大于等于/小于等于
match_phrase:短语检索。搜索条件不做任何分词解析,在搜索字段对应的倒排索引中精确匹配。

{
    "query":{
    "match_phrase":{
      搜索字段:搜索条件
     }
   }
}

term/terms:单词/词组搜索。搜索条件不做任何分词解析,在搜索字段对应的倒排索引中精确匹配

{
    "query":{
    "term":{ 
            搜索字段: 搜索条件
     }
   }
}

{
    "query":{
    "terms":{ 
            搜索字段: [搜索条件1,搜索条件2]
     }
   }
}

复合搜索

GET /索引/_search
{ 
    "query": { 
    "bool": { 
      // 必须满足的条件 
      "must": [ 
                搜索方式:搜索参数,
                搜索方式:搜索参数
       ],
      // 多个条件有任意一个满足即可
      "should": [
                搜索方式:搜索参数,
               搜索方式:搜索参数
           ],
            // 必须不满足的条件
           "must_not":[
               搜索方式:搜索参数,
               搜索方式:搜索参数
           ]
       } 
   } 
}

结果排序

ES中默认使用相关度分数实现排序,可以通过搜索语法定制化排序。

GET /索引/_search
{ 
  "query": 搜索条件,
  "sort": [
       {
           "字段1":{
               "order":"asc"
           } 
       },
       { 
           "字段2":{ 
               "order":"desc" 
           } 
       }
   ] 
}

由于ES对text类型字段数据会做分词处理,使用哪一个单词做排序都是不合理的,所以 ES中默认不允许对text类型的字段做排序。如果需要使用字符串做结果排序,可以使用 keyword类型的字段作为排序依据,因为keyword字段不做分词处理。

高亮查询

在进行关键字搜索时,搜索出的内容中的关键字会显示不同的颜色,称之为高亮。

我们可以在关键字左右加入标签字符串,数据传入前端即可完成高亮显示,ES可以对查询出的内容中关键字部分进行标签和样式的设置。

GET /索引/_search
{ 
    "query":搜索条件,
    "highlight":{
        "fields": { 
         "高亮显示的字段名": {
        // 返回高亮数据的最大长度
             "fragment_size":100,
                // 返回结果最多可以包含几段不连续的文字
             "number_of_fragments":5
         } 
     },
    "pre_tags":["前缀"], 
    "post_tags":["后缀"]
   } 
}

SQL查询

在ES7之后,支持SQL语句查询文档:

GET /_sql?format=txt
{
    "query": SQL语句
}

开源版本的ES并不支持通过Java操作SQL进行查询,如果需要操作 SQL查询,则需要氪金(购买白金版)

SpringDaraES案例

Java原生代码可以操作Elasticsearch,但操作比较繁琐,类似于数据库中的JDBC,我们还需要将ES文档手动封装为Java对象。所以开发中我们一般使用框架操作Elasticsearch。

Spring Data ElasticSearch是JAVA操作Elasticsearch的框架。它通过对原生API的封装,使得JAVA程序员可以简单的对Elasticsearch进行操作。

使用Repository继承的方法查询文档

  1. 创建SpringBoot项目,加入Spring Data Elasticsearch起步依赖:
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <scope>provided</scope>
</dependency>
  1. 编写配置文件,连接elasticsearch
spring:
  elasticsearch:
   uris: https://192.168.0.187:9200
   username: elastic
   password: elastic

#日志格式
logging:
  pattern:
    console: '%d{HH:mm:ss.SSS} %clr(%-5level) ---  [%-15thread] %cyan(%-50logger{50}):%msg%n'
  1. 创建配置类,跳过SSL证书检查。

从ES8开始,访问ES的协议从http变成了https,访问https请求需要SSL证书,在开发环境下我们不需要配置该证书,在项目中添加一个配置类,跳过SSL证书检查即可。

@Component
public class RestClientBuilderCustomizerImpl implements RestClientBuilderCustomizer {
  @Override
  public void customize(RestClientBuilder builder) {
   }


  /**
   * 跳过SSL的证书检查
   */
  @Override
  public void customize(HttpAsyncClientBuilder builder) {
    SSLContextBuilder sscb = SSLContexts.custom();
    try {
      sscb.loadTrustMaterial((chain, authType) -> {
        return true;
       });
     } catch (NoSuchAlgorithmException e) {
      throw new RuntimeException(e);
     } catch (KeyStoreException e) {
      throw new RuntimeException(e);
     }


    try {
      builder.setSSLContext(sscb.build());
     } catch (KeyManagementException | NoSuchAlgorithmException e) {
      e.printStackTrace();
     }
   }
}
  1. 创建实体类:
// 一个实体类的所有对象都会存入ES的一个索引中,所以我们在创建实体类时关联ES索引
@Document(indexName = "product",createIndex = true)
public class Product {
  @Id
  @Field(type = FieldType.Integer,store = true,index = true)
  private Integer id;
  @Field(type = FieldType.Text,store = true,index = true,analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
  private String productName;
  @Field(type = FieldType.Text,store = true,index = true,analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
  private String productDesc;
}

@Document:标记在类上,标记实体类为文档对象,一般有如下属性:

indexName:对应索引的名称

createIndex:是否自动创建索引

@Id:标记在成员变量上,标记一个字段为主键,该字段的值会同步到ES该文档的id值。

@Field:标记在成员变量上,标记为文档中的域,一般有如下属性:

type:域的类型

index:是否创建索引,默认是 true

store:是否单独存储,默认是 false

analyzer:分词器

searchAnalyzer:搜索时的分词器

  1. 创建Repository接口
// Repository接口继承ElasticsearchRepository,该接口提供了文档的增删改查方法
@Repository
public interface ProductRepository extends ElasticsearchRepository<Product,Integer> {
}
  1. 测试Repository接口的方法
@SpringBootTest
public class ProductRepositoryTest {
  @Autowired
  private ProductRepository repository;

  @Test
  public void addProduct(){
    Product product = new Product(1, "iphone30", "iphone30是苹果最新手机");
    repository.save(product);
   }

  @Test
  public void updateProduct(){
    Product product = new Product(1, "iphone31", "iphone31是苹果最新手机");
    repository.save(product);
   }

  @Test
  public void findAllProduct(){
    Iterable<Product> all = repository.findAll();
    for (Product product : all) {
      System.out.println(product);
     }
   }

  @Test
  public void findProductById(){
    Optional<Product> product = repository.findById(1);
    System.out.println(product.get());
   }

  @Test
  public void deleteProduct(){
    repository.deleteById(1);
   }
}

接下来我们讲解SpringDataES支持的查询方式,首先准备一些文档数据:

// 添加一些数据
repository.save(new Product(2, "三体1", "三体1是优秀的科幻小说"));
repository.save(new Product(3, "三体2", "三体2是优秀的科幻小说"));
repository.save(new Product(4, "三体3", "三体3是优秀的科幻小说"));
repository.save(new Product(5, "elasticsearch", "elasticsearch是基于lucene开发的优秀的搜索引擎"));

使用DSL语句查询文档

ES通过json类型的请求体查询文档,方法如下:

GET /索引/_search
{
  "query":{
    搜索方式:搜索参数
   }
}

query后的json对象称为DSL语句,我们可以在接口方法上使用@Query注解自定义DSL语句查询。

@Query("{" +
    "   \"match\": {" +
    "    \"productDesc\": \"?0\"" +
    "   }" +
    "  }")
List<Product> findByProductDescMatch(String keyword);


@Query("{" +
    " \"match\": {" +
    "  \"productDesc\": {" +
    "   \"query\": \"?0\"," +
    "   \"fuzziness\": 1" +
    "  }" +
    " }" +
    "}")
List<Product> findByProductDescFuzzy(String keyword);

按照规则命名方法查询文档

  • 只需在Repository接口中按照一定的规则命名方法,该方法就能完成相应的查询。
  • 规则:查询方法以findBy开头,涉及查询条件时,条件的属性用条件关键字连接。
关键字命名规则解释示例
andfindByField1AndField2根据Field1和Field2 获得数据findByTitleAndContent
orfindByField1OrField2根据Field1或Field2 获得数据findByTitleOrContent
isfindByField根据Field获得数据findByTitle
notfindByFieldNot根据Field获得补集数据findByTitleNot
betweenfindByFieldBetween获得指定范围的数据findByPriceBetween
List<Product> findByProductName(String productName);
List<Product> findByProductNameOrProductDesc(String productName,String productDesc);
List<Product> findByIdBetween(Integer startId,Integer endId);

分页查询

在使用继承或自定义的方法时,在方法中添加Pageable类型的参数,返回值为Page类型即可进行分页查询。

// 测试继承的方法:
@Test
public void testFindPage(){
  // 参数1:页数,参数2:每页条数
  Pageable pageable = PageRequest.of(1, 3);
  Page<Product> page = repository.findAll(pageable);
  System.out.println("总条数"+page.getTotalElements());
  System.out.println("总页数"+page.getTotalPages());
  System.out.println("数据"+page.getContent());
}


// 自定义方法
Page<Product> findByProductDescMatch(String keyword, Pageable pageable);


// 测试自定义方法
@Test
public void testFindPage2(){
  Pageable pageable = PageRequest.of(1, 2);
  Page<Product> page = repository.findByProductDescMatch("我喜欢三体", pageable);
  System.out.println("总条数"+page.getTotalElements());
  System.out.println("总页数"+page.getTotalPages());
  System.out.println("数据"+page.getContent());
}

结果排序

使用继承或自定义的方法时,在方法中添加Sort类型的参数即可进行结果排序。

// 结果排序
@Test
public void testFindSort(){
  Sort sort = Sort.by(Sort.Direction.DESC, "id");
  Iterable<Product> all = repository.findAll(sort);
  for (Product product : all) {
    System.out.println(product);
   }
}


// 测试分页加排序
@Test
public void testFindPage2(){
  Sort sort = Sort.by(Sort.Direction.DESC,"id");
  Pageable pageable = PageRequest.of(0, 2,sort);
  Page<Product> page = repository.findByProductDescMatch("我喜欢三体", pageable);
  System.out.println("总条数"+page.getTotalElements());
  System.out.println("总页数"+page.getTotalPages());
  System.out.println("数据"+page.getContent());
}

template工具类

SpringDataElasticsearch提供了一个工具类ElasticsearchTemplate,我们使用该类对象也可以对ES进行操作。

操作索引
@SpringBootTest
public class TemplateTest {
  @Autowired
  private ElasticsearchTemplate template;


  // 新增索引
  @Test
  public void addIndex() {
    // 获得索引操作对象
    IndexOperations indexOperations = template.indexOps(Product.class);
    // 创建索引,注:该方法无法设置索引结构,不推荐使用
    indexOperations.create();
   }


  // 删除索引
  @Test
  public void delIndex() {
    // 获得索引操作对象
    IndexOperations indexOperations = template.indexOps(Product.class);
    // 删除索引
    indexOperations.delete();
   }
}

操作文档
// 新增/修改文档
@Test
public void addDocument() {
  Product product = new Product(7, "es1", "es是一款优秀的搜索引擎");
  template.save(product);
}


// 删除文档
@Test
public void delDocument() {
  template.delete("7", Product.class);
}


// 根据id查询
@Test
public void findAllDocument() {
  Product product = template.get("1", Product.class);
  System.out.println(product);
}

查询文档

template的search方法可以查询文档:

SearchHits<T> search(Query query, Class<T> clazz):查询文档,query是查询条件对象,clazz是结果类型。

用法如下:

// 查询文档2
@Test
public void searchDocument2() {
  String productName = "三体";
  String productDesc = "优秀";


  // 1.构建查询条件
  NativeQueryBuilder nativeQueryBuilder = new NativeQueryBuilder();
  // 如果没有传入参数,查询所有
  if (productName == null && productDesc == null) {
    nativeQueryBuilder.withQuery(Queries.matchAllQueryAsQuery());
   } else {
    BoolQuery.Builder boolQuery = QueryBuilders.bool();
    if (productName != null) {
      boolQuery.must(Queries.matchQueryAsQuery("productName", productName, null, null));
     }
    if (productDesc != null) {
      boolQuery.must(Queries.matchQueryAsQuery("productDesc", productDesc, null, null));
     }
    nativeQueryBuilder.withQuery(boolQuery.build()._toQuery());
   }
  NativeQuery query = nativeQueryBuilder.build();


  // 2.查询
  SearchHits<Product> result = template.search(query, Product.class);
  // 3.处理查询结果
  for (SearchHit<Product> productSearchHit : result) {
    Product product = productSearchHit.getContent();
    System.out.println(product);
   }
}
复杂条件查询
// 查询文档2
@Test
public void searchDocument2(){
  String productName = "三体";
  String productDesc = "优秀";


  // 1.构造查询条件
  NativeQueryBuilder nativeQueryBuilder = new NativeQueryBuilder();
  // 如果没有传入参数,查询所有
  if (productName == null && productDesc == null){
    nativeQueryBuilder.withQuery(Queries.matchAllQueryAsQuery());
   }else {
    BoolQuery.Builder boolQuery = QueryBuilders.bool();
    if (productName != null){
      boolQuery.must(Queries.matchQueryAsQuery("productName",productName,null,null));
     }
    if (productDesc != null){
      boolQuery.must(Queries.matchQueryAsQuery("productDesc",productDesc,null,null));
     }
    nativeQueryBuilder.withQuery(boolQuery.build()._toQuery());
   }
  NativeQuery query = nativeQueryBuilder.build();


  // 2.查询
  SearchHits<Product> result = template.search(query, Product.class);
  // 3.处理查询结果
  for (SearchHit<Product> productSearchHit : result) {
    Product product = productSearchHit.getContent();
    System.out.println(product);
   }
}

分页查询
// 分页查询文档
@Test
public void searchDocumentPage() {


  // 1.构建查询条件
  Pageable pageable = PageRequest.of(0, 3);
  NativeQuery query = new NativeQueryBuilder()
     .withQuery(Queries.matchAllQueryAsQuery())
     .withPageable(pageable)
     .build();
  // 2.查询
  SearchHits<Product> result = template.search(query, Product.class);


  // 3.处理查询结果
  List<Product> content = new ArrayList();
  for (SearchHit<Product> productSearchHit : result) {
    Product product = productSearchHit.getContent();
    content.add(product);
   }


  /**
     * 封装Page对象,参数1:具体数据,参数2:分页条件对象,参数3:总条数
     */
  Page<Product> page = new PageImpl(content, pageable, result.getTotalHits());


  System.out.println(page.getTotalElements());
  System.out.println(page.getTotalPages());
  System.out.println(page.getContent());
}

结果排序
// 结果排序
@Test
public void searchDocumentSort() {
  // 1.构建查询条件
  NativeQuery query = new NativeQueryBuilder()
     .withQuery(Queries.matchAllQueryAsQuery())
     .withSort(Sort.by(Sort.Direction.DESC, "id"))
     .build();
  // 2.查询
  SearchHits<Product> result = template.search(query, Product.class);


  // 3.处理查询结果
  for (SearchHit<Product> productSearchHit : result) {
    Product product = productSearchHit.getContent();
    System.out.println(product);
   }
}

Elasticsearch集群

在这里插入图片描述
在单台ES服务器上,随着一个索引内数据的增多,会产生存储、效率、安全等问题。

  1. 假设项目中有一个500G大小的索引,但我们只有几台200G硬盘的服务器,此时是不可能将索引放入其中某一台服务器中的。

在这里插入图片描述

  1. 此时我们需要将索引拆分成多份,分别放入不同的服务器中,此时这几台服务器维护了同一个索引,我们称这几台服务器为一个集群,其中的每一台服务器为一个节点,每一台服务器中的数据称为一个分片

在这里插入图片描述

  1. 此时如果某个节点故障,则会造成集群崩溃,所以每个节点的分片往往还会创建副本,存放在其他节点中,此时一个节点的崩溃就不会影响整个集群的正常运行。
    在这里插入图片描述
    节点(node):一个节点是集群中的一台服务器,是集群的一部分。它存储数据,参与集群的索引和搜索功能。集群中有一个为主节点,主节点通过ES内部选举产生。

集群(cluster):一组节点组织在一起称为一个集群,它们共同持有整个的数据,并一起提供索引和搜索功能。

分片(shards):ES可以把完整的索引分成多个分片,分别存储在不同的节点上。

副本(replicas):ES可以为每个分片创建副本,提高查询效率,保证在分片数据丢失后的恢复。

粗样式**

注:

分片的数量只能在索引创建时指定,索引创建后不能再更改分片数量,但可以改变副本的数量。

为保证节点发生故障后集群的正常运行,ES不会将某个分片和它的副本存在同一台节点上。

搭建集群

在这里插入图片描述

安装第一个ES节点

  1. 修改系统进程最大打开文件数
#修改系统文件
vim /etc/security/limits.conf


#添加如下内容
es soft nofile 65535
es hard nofile 131072
  1. 安装
#解压:
tar -zxvf elasticsearch-8.10.4-linux-x86_64.tar.gz


#重命名:
mv elasticsearch-8.10.4 myes1


#移动文件夹:
mv myes1 /usr/local/


#安装ik分词器
unzip elasticsearch-analysis-ik-8.10.4.zip -d /usr/local/myes1/plugins/analysis-ik


#安装拼音分词器
unzip elasticsearch-analysis-pinyin-8.10.4.zip -d /usr/local/myes1/plugins/analysis-pinyin


#es用户取得该文件夹权限:
chown -R es:es /usr/local/myes1
  1. 修改配置文件
#打开节点一配置文件:
vim /usr/local/myes1/config/elasticsearch.yml

配置如下信息:

#集群名称,保证唯一
cluster.name: my_elasticsearch
#节点名称,必须不一样
node.name: node1
#可以访问该节点的ip地址
network.host: 0.0.0.0
#该节点服务端口号
http.port: 9200
#集群间通信端口号
transport.port: 9300
#候选主节点的设备地址
discovery.seed_hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"]
#候选主节点的节点名
cluster.initial_master_nodes: ["node1","node2","node3"]
#关闭安全认证
xpack.security.enabled: false
  1. 启动
#切换为es用户:
su es


#后台启动第一个节点:
ES_JAVA_OPTS="-Xms512m -Xmx512m" /usr/local/myes1/bin/elasticsearch -d

安装第二个ES节点

  1. 安装
#解压:
tar -zxvf elasticsearch-8.10.4-linux-x86_64.tar.gz

#重命名:
mv elasticsearch-8.10.4 myes2

#移动文件夹:
mv myes2 /usr/local/

#安装ik分词器
unzip elasticsearch-analysis-ik-8.10.4.zip -d /usr/local/myes2/plugins/analysis-ik

#安装拼音分词器
unzip elasticsearch-analysis-pinyin-8.10.4.zip -d /usr/local/myes2/plugins/analysis-pinyin

#es用户取得该文件夹权限:
chown -R es:es /usr/local/myes2
  1. 修改配置文件
#打开节点二配置文件:
vim /usr/local/myes2/config/elasticsearch.yml
配置如下信息:
#集群名称,保证唯一
cluster.name: my_elasticsearch
#节点名称,必须不一样
node.name: node2
#可以访问该节点的ip地址
network.host: 0.0.0.0
#该节点服务端口号
http.port: 9201
#集群间通信端口号
transport.port: 9301
#候选主节点的设备地址
discovery.seed_hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"]
#候选主节点的节点名
cluster.initial_master_nodes: ["node1","node2","node3"]
#关闭安全认证
xpack.security.enabled: false
  1. 启动
#切换为es用户:
su es

#后台启动第二个节点:
ES_JAVA_OPTS="-Xms512m -Xmx512m" /usr/local/myes2/bin/elasticsearch -d

安装第三个ES节点

  1. 安装
#解压:
tar -zxvf elasticsearch-8.10.4-linux-x86_64.tar.gz

#重命名:
mv elasticsearch-8.10.4 myes3

#移动文件夹:
mv myes3 /usr/local/

#安装ik分词器
unzip elasticsearch-analysis-ik-8.10.4.zip -d /usr/local/myes3/plugins/analysis-ik

#安装拼音分词器
unzip elasticsearch-analysis-pinyin-8.10.4.zip -d /usr/local/myes3/plugins/analysis-pinyin

#es用户取得该文件夹权限:
chown -R es:es /usr/local/myes3
  1. 修改配置文件
#打开节点一配置文件:
vim /usr/local/myes3/config/elasticsearch.yml

配置如下信息:

#集群名称,保证唯一
cluster.name: my_elasticsearch
#节点名称,必须不一样
node.name: node3
#可以访问该节点的ip地址
network.host: 0.0.0.0
#该节点服务端口号
http.port: 9202
#集群间通信端口号
transport.port: 9302
#候选主节点的设备地址
discovery.seed_hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"]
#候选主节点的节点名
cluster.initial_master_nodes: ["node1","node2","node3"]
#关闭安全认证
xpack.security.enabled: false
  1. 启动
#切换为es用户:
su es

#后台启动第三个节点:
ES_JAVA_OPTS="-Xms512m -Xmx512m" /usr/local/myes3/bin/elasticsearch -d

测试集群
访问http://虚拟机IP:9200/_cat/nodes查看集群是否搭建成功。

kibana连接es集群

  1. 在kibana中访问集群
# 打开kibana配置文件
vim /usr/local/kibana-8.10.4/config/kibana.yml

添加如下配置

# 该集群的所有节点
elasticsearch.hosts: ["http://127.0.0.1:9200","http://127.0.0.1:9201","http://127.0.0.1:9202"]

启动kibana

#切换为es用户:
su es

#启动kibana:
cd /usr/local/kibana-8.10.4/bin
./kibana
  1. 访问kibana:http://虚拟机IP:5601

测试集群状态

  1. 在集群中创建一个索引
PUT /product1
{
 "settings": {
  "number_of_shards": 5, // 分片数
  "number_of_replicas": 1 // 每个分片的副本数
  },
 "mappings": {
  "properties": {
   "id": {
    "type": "integer",
    "store": true,
    "index": true
    },
   "productName": {
    "type": "text",
    "store": true,
    "index": true
    },
   "productDesc": {
    "type": "text",
    "store": true,
    "index": true
    }
   }
  }
}
  1. 查看集群状态
# 查看集群健康状态
GET /_cat/health?v
# 查看索引状态
GET /_cat/indices?v
# 查看分片状态
GET /_cat/shards?v 

故障应对和水平扩容

  1. 关闭一个节点,可以发现ES集群可以自动进行故障应对。

  2. 重新打开该节点,可以发现ES集群可以自动进行水平扩容。

  3. 分片数不能改变,但是可以改变每个分片的副本数、

PUT /索引/_settings
{
  "number_of_replicas": 副本数
}

内存设置

ES默认占用内存是4GB,我们可以修改config/jvm.option设置ES的堆内存大小,Xms表示堆内存的初始大小,Xmx表示可分配的最大内存。

  • Xmx和Xms的大小设置为相同的,可以减轻伸缩堆大小带来的压力。
  • Xmx和Xms不要超过物理内存的50%,因为ES内部的Lucene也要占据一部分物理内存。
  • Xmx和Xms不要超过32GB,由于Java语言的特性,堆内存超过32G会浪费大量系统资源,所以在内存足够的情况下,最终我们都会采用设置为31G:
-Xms 31g
-Xmx 31g

例如:在一台128GB内存的机器中,我们可以创建两个节点,每个节点分配31GB内存。

磁盘选择

ES的优化即通过调整参数使得读写性能更快

磁盘通常是服务器的瓶颈。Elasticsearch重度使用磁盘,磁盘的效率越高,Elasticsearch的执行效率就越高。这里有一些优化磁盘的技巧:

  • 使用SSD(固态硬盘),它比机械磁盘优秀多了。
  • 使用RAID0模式(将连续的数据分散到多个硬盘存储,这样可以并行进行IO操作),代价是一块硬盘发生故障就会引发系统故障。
  • 不要使用远程挂载的存储。

分片策略

分片和副本数并不是越多越好。每个分片的底层都是一个Lucene索引,会消耗一定的系统资源。且搜索请求需要命中索引中的所有分片,分片数过多会降低搜索性能。索引的分片数需要架构师和技术人员对业务的增长有预先的判断,一般来说我们遵循以下原则:

  • 每个分片占用的硬盘容量不超过ES的最大JVM的堆空间设置(一般设置不超过32G)。比如:如果索引的总容量在500G左右,那分片数量在16个左右即可。

  • 分片数一般不超过节点数的3倍。比如:如果集群内有10个节点,则分片数不超过30个。

  • 推迟分片分配:节点中断后集群会重新分配分片。但默认集群会等待一分钟来查看节点是否重新加入。我们可以设置等待的时长,减少重新分配的次数:

PUT /索引/_settings
{
  "settings":{
    "index.unassianed.node_left.delayed_timeout":"5m"
   }
}
  • 减少副本数量:进行写入操作时,需要把写入的数据都同步到副本,副本越多写入的效率就越慢。我们进行大批量进行写入操作时可以先设置副本数为0,写入完成后再修改回正常的状态。

Elasticsearch案例

需求说明

使用ES模仿百度搜索,即自动补全+搜索引擎效果:

ES自动补全

GET /索引/_search
{
  "suggest": {
    "prefix_suggestion": {// 自定义推荐名
      "prefix": "elastic",// 被补全的关键字
      "completion": {
        "field": "productName",// 查询的域
        "skip_duplicates": true, // 忽略重复结果
        "size": 10 //最多查询到的结果数
       }
     }
   }
}

注:自动补全对性能要求极高,ES不是通过倒排索引来实现的,所以需要将对应的查询字段类型设置为completion。

PUT /product2
{
  "mappings":{
    "properties":{
      "id":{
        "type":"integer",
        "store":true,
        "index":true
       },
      "productName":{ 
        "type":"completion"
       },
      "productDesc":{ 
        "type":"text",
        "store":true,
        "index":true
       }
     }
   }
}


POST /product2/_doc
{
  "id":1,
  "productName":"elasticsearch1",
  "productDesc":"elasticsearch1 is a good search engine"
}


POST /product2/_doc
{
  "id":2,
  "productName":"elasticsearch2",
  "productDesc":"elasticsearch2 is a good search engine"
}


POST /product2/_doc
{
  "id":3,
  "productName":"elasticsearch3",
  "productDesc":"elasticsearch3 is a good search engine"
}

创建索引

PUT /news
{
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "ik_pinyin": {
          "tokenizer": "ik_smart",
          "filter": "pinyin_filter"
         },
        "tag_pinyin": {
          "tokenizer": "keyword",
          "filter": "pinyin_filter"
         }
       },
      "filter": {
        "pinyin_filter": {
          "type": "pinyin",
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "remove_duplicated_term": true
         }
       }
     }
   },
  "mappings": {
    "properties": {
      "id": {
        "type": "integer",
        "index": true
       },
      "title": {
        "type": "text",
        "index": true,
        "analyzer": "ik_pinyin",
        "search_analyzer": "ik_smart"
       },
      "content": {
        "type": "text",
        "index": true,
        "analyzer": "ik_pinyin",
        "search_analyzer": "ik_smart"
       },
      "url": {
        "type": "keyword",
        "index": true
       },
      "tags": {
        "type": "completion",
        "analyzer": "tag_pinyin",
        "search_analyzer": "tag_pinyin"
       }
     }
   }
}

mysql数据导入es

使用logstash工具可以将mysql数据同步到es中:

  1. 解压logstash-8.10.4-windows-x86_64

logstash要和elastisearch版本一致

  1. 在logstash解压路径下的/config中创建mysql.conf文件,文件写入以下脚本内容:
input {
   jdbc {
     jdbc_driver_library => "F:\Elasticsearch8\mysql-connector-java-5.1.37-bin.jar"
     jdbc_driver_class => "com.mysql.jdbc.Driver"
     jdbc_connection_string => "jdbc:mysql:///news"
     jdbc_user => "root"
     jdbc_password => "root"
     schedule => "* * * * *"
     jdbc_default_timezone => "Asia/Shanghai"
     statement => "SELECT * FROM news;"
   }
}


filter {
    mutate {
        split => {"tags" => ","}
    }
}


output {
   elasticsearch {
        hosts => ["http://192.168.0.187:9200","http://192.168.0.187:9201","http://192.168.0.187:9202"]
     index => "news"
       document_id => "%{id}"
   }
}
  1. 在解压路径下打开cmd黑窗口,运行命令:
bin\logstash -f config\mysql.conf
  1. 测试自动补齐
GET /news/_search
{
  "suggest": {
    "my_suggest": {
      "prefix": "li",
      "completion": {
        "field": "tags",
        "skip_duplicates": true,
        "size": 10
       }
     }
   }
}

项目搭建

创建Springboot项目,加入SpringDataElasticsearch和SpringMVC的起步依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <optional>true</optional>
</dependency>

写配置文件:

# 连接elasticsearch
spring:
  elasticsearch:
   uris: 192.168.0.187:9200,192.168.0.187:9201,192.168.0.187:9202


# 日志格式
logging:
  pattern:
  console: '%d{HH:mm:ss.SSS} %clr(%-5level) ---  [%-15thread] %cyan(%-50logger{50}):%msg%n'

创建实体类

@Document(indexName = "news")
@Data
public class News {
  @Id
  @Field
  private Integer id;
  @Field
  private String title;
  @Field
  private String content;
  @Field
  private String url;
  @CompletionField
  @Transient
  private Completion tags;
}

创建Repository接口

@Repository
public interface NewsRepository extends ElasticsearchRepository<News, Integer> {
  
}

自动补全功能

@Service
public class NewsService {
  @Autowired
  private ElasticsearchClient client;
 
  // 自动补齐
  public List<String> autoSuggest(String keyword) throws IOException {
    // 1.自动补齐查询条件
    Suggester suggester = Suggester.of(
        s -> s.suggesters("prefix_suggestion", FieldSuggester.of(
            fs -> fs.completion(
                cs -> cs.skipDuplicates(true)
                     .size(10)
                     .field("tags")


             )
         )).text(keyword)
     );
    // 2.自动补齐查询
    SearchResponse<Map> response = client.search(s -> s.index("news")
         .suggest(suggester), Map.class);


    // 3.处理查询结果
    Map resultMap = response.suggest();
    List<Suggestion> suggestionList = (List) resultMap.get("prefix_suggestion");
    Suggestion suggestion = suggestionList.get(0);
    List<CompletionSuggestOption> resultList = suggestion.completion().options();


    List<String> result = new ArrayList<>();
    for (CompletionSuggestOption completionSuggestOption : resultList) {
      String text = completionSuggestOption.text();
      result.add(text);
     }
    return result;
   }
}

搜索关键字功能

在repository接口中添加高亮搜索关键字方法

// 高亮搜索关键字
@Highlight(fields = {@HighlightField(name = "title"), @HighlightField(name = "content")})
List<SearchHit<News>> findByTitleMatchesOrContentMatches(String title, String content);

service类中调用该方法

// 查询关键字
public List<News> highLightSearch(String keyword){
  List<SearchHit<News>> result = repository.findByTitleMatchesOrContentMatches(keyword, keyword);
  // 处理结果,封装为News类型的集合
  List<News> newsList = new ArrayList();
  for (SearchHit<News> newsSearchHit : result) {
    News news = newsSearchHit.getContent();
    // 高亮字段
    Map<String, List<String>> highlightFields = newsSearchHit.getHighlightFields();
    if (highlightFields.get("title") != null){
      news.setTitle(highlightFields.get("title").get(0));
     }
    if (highlightFields.get("content") != null){
      news.setContent(highlightFields.get("content").get(0));
     }
    newsList.add(news);
   }
  return newsList;
}

创建Controller类

@RestController
public class NewsController {
  @Autowired
  private NewsService newsService;
  
  @GetMapping("/autoSuggest")
  public List<String> autoSuggest(String term){ // 前端使用jqueryUI,发送的参数默认名为term
    return newsService.autoSuggest(term);
   }
  
  @GetMapping("/highLightSearch")
  public List<News> highLightSearch(String term){
    return newsService.highLightSearch(term);
   }
}

前端页面

我们使用jqueryUI中的autocomplete插件完成项目的前端实现。

<script>
  $("#newsTag").autocomplete({
    source: "/autoSuggest", // 请求路径
    delay: 100, //请求延迟
    minLength: 1 //最少输入多少字符像服务器发送请求
   })


  function search() {
    var term = $("#newsTag").val();
    $.get("/highLightSearch", {term: term}, function (data) {
      var str = "";
      for (var i = 0; i < data.length; i++) {
        var document = data[i];
        str += "<li>" +
          "    <h4>" +
          "      <a href='" + document.url + "' target='_blank'>" + document.title + "</a>" +
          "    </h4> " +
          "    <p>" + document.content + "</p>" +
          "  </li>";
       }
      $("#news").html(str);
     })
   }
</script>

如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

中北萌新程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值