1 - Solr

SpringBoot整合Solr,支撑搜索业务场景

1.1 - 1.环境搭建与简单测试

搜索可以说是非常常见的场景了,一般选择比较多的有solr和es,底层都是基于Lucene搜索引擎实现。之前简单的使用过solr,一直没有成体系的学习过,正好需要给一个内部项目封装统一的查询组件,借这个机会好好的撸一把solr的知识要点

I. Solr环境搭建

1. docker方式安装solr

使用docker实现solr环境的搭建,快速简洁

docker pull solr

启动solr容器

docker run --name my-solr -d -p 8983:8983 -t solr

浏览器打开: http://localhost:8983/solr/#/

新建core

docker exec -it --user=solr my-solr bin/solr create_core -c yhh

建立成功之后,终端会有相应的提示,然后刷新浏览器,可以看到新的yhh

控制台

2. schema

通过docker安装的最新solr版本为8.0,可以直接在http界面通过控制台来创建schema,而不需要像以前那样,进入配置文件进行添加处理;当然也可以通过修改对应的配置

假定我们现在需要在yhh这个core中存文章,结构为

id: string # 默认的全局唯一字段
title: string # 文章标题
content: string # 文章内容
type: int # 文章类型

a. 控制台添加方式

首先进入schema的页面,可以如下操作,也可以点击连接: http://localhost:8983/solr/#/yhh/schema

1

然后通过点击Add Field按钮添加字段,确认按钮之后完成添加

2

添加完成之后点击please select...,弹出下拉框,看到刚才添加的东西

3

b. 编辑xml文件方式

通过控制台的overiew可以定位到core存储路径,然后我们找到对应的定义文件,添加两个字段

create_at: long # 文章创建时间
publish_at: long # 文章发布时间

修改配置文件

# 首先进入docker内
docker exec -it --user=root my-solr /bin/bash

# 定位配置文件
vim /var/solr/data/yhh/conf/managed-schema

# 新增字段
<field name="create_at" type="pint" uninvertible="true" default="0" indexed="true" stored="true" />
<field name="publish_at" type="pint" uninvertible="true" default="0" indexed="true" stored="true" />

修改完成之后如下图

xml

配置文件修改之后,再去刷控制台,发现并没有显示出来,通过重启solr之后,新的才显示出来

show

c. 功能测试

schema定义完毕之后,就可以进行简单的测试了,先加几个文档;然后再进行查询

添加文档

直接在控制台进行添加: http://localhost:8983/solr/#/yhh/documents

{
id: 2,
title: "一灰灰",
content: "欢迎来到一灰灰的博客",
type: 1,
create_at: 1557488164,
publish_at: 1557488164
}

添加文档

文档查询

直接在控制台进行操作:http://localhost:8983/solr/#/yhh/query

文档查询

然后来个高级一点的查询,我希望查询所有内容包含一灰灰的数据,可以如下查询

文档查询

II. SpringBoot搭建solr环境

1. 配置

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.4.RELEASE</version>
    <relativePath/> <!-- lookup parent from update -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
    <java.version>1.8</java.version>
</properties>


<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-solr</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

上面的配置中,需要注意是引入包 spring-boot-starter-data-solr

引入这个包之后,我们就可以愉快的使用SolrTemplate来完成solr的各种骚操作了

package com.git.hui.boot.solr.config;

import org.apache.solr.client.solrj.SolrClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.solr.core.SolrTemplate;

/**
 * Created by @author yihui in 19:49 19/5/10.
 */
@Configuration
public class SearchAutoConfig {

    @Bean
    @ConditionalOnMissingBean(SolrTemplate.class)
    public SolrTemplate solrTemplate(SolrClient solrClient) {
        return new SolrTemplate(solrClient);
    }
}

2. 简单测试

下面搞一个简单的查询,看下能不能获取到solr文档

package com.git.hui.boot.solr.solr;

import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.solr.core.SolrTemplate;
import org.springframework.stereotype.Component;


/**
 * Created by @author yihui in 19:45 19/5/10.
 */
@Component
public class SolrSearchService {
    @Autowired
    private SolrTemplate solrTemplate;

    @Data
    public static class DocDO {
        private Integer id;
        private String title;
        private String content;
        private Integer type;
        private Long create_at;
        private Long publish_at;
    }

    public void query() {
        DocDO ans = solrTemplate.getById("yhh", 2, DocDO.class).get();
        System.out.println(ans);
    }
}

3. 测试

启动下任务开始测试

package com.git.hui.boot.solr;

import com.git.hui.boot.solr.solr.SolrSearchService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Created by @author yihui in 19:44 19/5/10.
 */
@SpringBootApplication
public class Application {
    private SolrSearchService solrSearchService;

    public Application(SolrSearchService solrSearchService) {
        this.solrSearchService = solrSearchService;
        query();
    }

    private void query() {
        this.solrSearchService.query();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

执行截图如下

测试输出

III. 小结

上面介绍了最基础的solr环境搭建,springboot的solr测试环境准备,并实现了一个简单的查询实例,但距离真正上手撸solr还缺不少东西

  • solr的基础知识,前面的字段定义是否合法,索引什么的改怎么考虑
  • 配置修改,安全保证
  • 中文分词如何设置,如何使用在solr中进行使用
  • solr的增删改查的基本操作姿势
  • solr的全文搜索优势如何体现
  • SpringBoot中进行solr操作

0. 项目

1.2 - 2.文档新增与修改使用姿势

大多涉及到数据的处理,无非CURD四种操作,对于搜索SOLR而言,基本操作也可以说就这么几种,在实际应用中,搜索条件的多样性才是重点,我们在进入复杂的搜索之前,先来看一下如何新增和修改文档

I. 环境准备

solr的基础环境需要准备好,如果对这一块有疑问的童鞋,可以参考下上一篇博文: 《190510-SpringBoot高级篇搜索之Solr环境搭建与简单测试

1. 环境配置

在pom文件中,设置好对应的依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.4.RELEASE</version>
    <relativePath/> <!-- lookup parent from update -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
    <java.version>1.8</java.version>
</properties>


<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-solr</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

因为我们采用默认的solr访问姿势,所以配置文件中可以不加对应的参数,当然也可以加上

打开 application.yml 配置文件

spring:
  data:
    solr:
      host: http://127.0.0.1:8983/solr

如果我们的solr加上了用户名密码访问条件,参数中并没有地方设置username和password,那应该怎么办?

spring:
  data:
    solr:
      host: http://admin:admin@127.0.0.1:8983/solr

如上写法,将用户名和密码写入http的连接中

2. 自动装配

我们主要使用SolrTemplate来和Solr打交到,因此我们需要先注册这个bean,可以怎么办?

package com.git.hui.boot.solr.config;

import org.apache.solr.client.solrj.SolrClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.solr.core.SolrTemplate;

/**
 * Created by @author yihui in 19:49 19/5/10.
 */
@Configuration
public class SearchAutoConfig {

    @Bean
    @ConditionalOnMissingBean(SolrTemplate.class)
    public SolrTemplate solrTemplate(SolrClient solrClient) {
        return new SolrTemplate(solrClient);
    }
}

上面的配置是条件注入,只有当SolrTemplate对应的bean没有被自动加载时,才会加载,为什么要怎么干?

(可以想一想原因…)

II. 使用姿势示例

我们的操作主要依赖的是SolrTemplate,因此有必要在开始之前,看一下它的签名

Spring的源码中,可以发现大多xxxTemplate都会实现一个xxxOperations 接口,而这个接口就是用来定义CURD的api,比如我们看下 SolrOperations中与修改相关的api

default UpdateResponse saveBean(String collection, Object obj) {
	return saveBean(collection, obj, Duration.ZERO);
}

/**
 * Execute add operation against solr, which will do either insert or update with support for commitWithin strategy.
 *
 * @param collection must not be {@literal null}.
 * @param obj must not be {@literal null}.
 * @param commitWithin max time within server performs commit.
 * @return {@link UpdateResponse} containing update result.
 */
UpdateResponse saveBean(String collection, Object obj, Duration commitWithin);

default UpdateResponse saveBeans(String collection, Collection<?> beans) {
	return saveBeans(collection, beans, Duration.ZERO);
}

UpdateResponse saveBeans(String collection, Collection<?> beans, Duration commitWithin);

default UpdateResponse saveDocument(String collection, SolrInputDocument document) {
	return saveDocument(collection, document, Duration.ZERO);
}

/**
 * Add a solrj input document to solr, which will do either insert or update with support for commitWithin strategy
 *
 * @param document must not be {@literal null}.
 * @param commitWithin must not be {@literal null}.
 * @return {@link UpdateResponse} containing update result.
 * @since 3.0
 */
UpdateResponse saveDocument(String collection, SolrInputDocument document, Duration commitWithin);


default UpdateResponse saveDocuments(String collection, Collection<SolrInputDocument> documents) {
	return saveDocuments(collection, documents, Duration.ZERO);
}

UpdateResponse saveDocuments(String collection, Collection<SolrInputDocument> documents, Duration commitWithin);

上面的api签名中,比较明确的说明了这个 saveXXX 既可以用来新增文档,也可以用来修改文档,主要有提供了两类

  • 单个与批量
  • saveDocument 与 saveBean

1. 添加文档

从上面的api签名上看,saveDocument 应该是相对原始的操作方式了,因此我们先看下它的使用姿势

a. saveDocument

首先就是创建文档 SolrInputDocument 对象,通过调用addField来设置成员值

public void testAddByDoc() {
    SolrInputDocument document = new SolrInputDocument();
    document.addField("id", 3);
    document.addField("title", "testAddByDoc!");
    document.addField("content", "通过solrTemplate新增文档");
    document.addField("type", 2);
    document.addField("create_at", System.currentTimeMillis() / 1000);
    document.addField("publish_at", System.currentTimeMillis() / 1000);

    UpdateResponse response = solrTemplate.saveDocument("yhh", document);
    solrTemplate.commit("yhh");
    System.out.println("over:" + response);
}

注意:保存文档之后,一定得调用commit提交

b. saveBean

前面需要创建SolrInputDocument对象,我们更希望的使用case是直接传入一个POJO,然后自动与solr的filed进行关联

因此一种使用方式可以如下

  • 定义pojo,成员上通过 @Field 注解来关联solr的field
  • pojo对象直接当做参数传入,保存之后,执行 commit 提交
@Data
public static class DocDO {
    @Field("id")
    private Integer id;
    @Field("title")
    private String title;
    @Field("content")
    private String content;
    @Field("type")
    private Integer type;
    @Field("create_at")
    private Long createAt;
    @Field("publish_at")
    private Long publishAt;
}

/**
 * 新增
 */
private void testAddByBean() {
    DocDO docDO = new DocDO();
    docDO.setId(4);
    docDO.setTitle("addByBean");
    docDO.setContent("新增一个测试文档");
    docDO.setType(1);
    docDO.setCreateAt(System.currentTimeMillis() / 1000);
    docDO.setPublishAt(System.currentTimeMillis() / 1000);

    UpdateResponse response = solrTemplate.saveBean("yhh", docDO);
    solrTemplate.commit("yhh");
    System.out.println(response);
}

c. 批量

批量的方式就比较简单了,传入集合即可

private void testBatchAddByBean() {
    DocDO docDO = new DocDO();
    docDO.setId(5);
    docDO.setTitle("addBatchByBean - 1");
    docDO.setContent("新增一个测试文档");
    docDO.setType(1);
    docDO.setCreateAt(System.currentTimeMillis() / 1000);
    docDO.setPublishAt(System.currentTimeMillis() / 1000);

    DocDO docDO2 = new DocDO();
    docDO2.setId(6);
    docDO2.setTitle("addBatchByBean - 2");
    docDO2.setContent("新增一个测试文档");
    docDO2.setType(1);
    docDO2.setCreateAt(System.currentTimeMillis() / 1000);
    docDO2.setPublishAt(System.currentTimeMillis() / 1000);

    UpdateResponse response = solrTemplate.saveBeans("yhh", Arrays.asList(docDO, docDO2));
    solrTemplate.commit("yhh");
    System.out.println(response);
}

d. 测试

上面的几个方法,我们执行之后,我们看下是否能查询到新增加的数据

output

2. 文档修改

在看前面的接口签名时,就知道修改和新增用的是相同的api,所以修改文档和上面的使用实际上也没有什么特别的,下面简单的演示一下

public void testUpdateDoc() {
    DocDO docDO = new DocDO();
    docDO.setId(5);
    docDO.setTitle("修改之后!!!");
    docDO.setType(1);
    docDO.setCreateAt(System.currentTimeMillis() / 1000);
    docDO.setPublishAt(System.currentTimeMillis() / 1000);

    UpdateResponse response = solrTemplate.saveBean("yhh", docDO);
    solrTemplate.commit("yhh");
    System.out.println(response);
}

上面的实例中,修改了id为5的文档标题,并删除了content内容,执行完毕之后,结果如何呢?

output

  • title被替换
  • content没有了

到这里就有个疑问了,对于调用而言,怎么保证是修改还是新增呢?

  • 这里主要是根据id来判断,这个id类似db中的唯一主键,当我们没有指定id时,会随机生成一个id
  • 如果存在相同的id,则修改文档;如果不存在,则新增文档

III. 其他

0. 项目

1.3 - 3.Solr文档删除

之前的搜索教程开了个头就没有继续了,现在重新捡回来,至少也把CURD的基本操作姿势补全了;本篇主要介绍如何删除数据

I. 配置

在介绍demo之前,需要先安装solr环境,搭建SpringBoot项目工程,具体的环境搭建过程不细说,推荐参考文档

application.yml 配置文件中红,指定solr的域名

spring:
  data:
    solr:
      host: http://127.0.0.1:8983/solr

然后在solr中,写入一些数据,供我们删除使用,可以通过控制台的方式写入,也可以通过190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势 这篇文档的case添加

{
  "id":"1",
  "content_id":1,
  "title":"一灰灰blog",
  "content":"这是一灰灰blog的内容",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655609540674060288},
{
  "id":"2",
  "content_id":2,
  "title":"一灰灰",
  "content":"这是一灰灰的内容",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655609550229733376},
{
  "id":"3",
  "content_id":3,
  "title":"solrTemplate 修改之后!!!",
  "create_at":1578912072,
  "publish_at":1578912072,
  "type":0,
  "_version_":1655609304941592576},
{
  "id":"4",
  "content_id":4,
  "type":1,
  "create_at":0,
  "publish_at":0,
  "_version_":1655609305022332928},
{
  "id":"5",
  "content_id":5,
  "title":"addBatchByBean - 1",
  "content":"新增一个测试文档",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655609304836734976},
{
  "id":"6",
  "content_id":6,
  "title":"addBatchByBean - 2",
  "content":"新增又一个测试文档",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655684018701598720
}

II. 删除

我们依然是使用SolrTemplate来操作solr的正删改查,它整合了solr的各种基本操作

1. 根据主键删除

请注意,这种case是根据主键id进行删除的,支持批量删除,需要solrTemplate.commit("yhh");这一行来提交修改

private void deleteById() {
    solrTemplate.deleteByIds("yhh", Arrays.asList("4"));
    solrTemplate.commit("yhh");
}

2. 查询删除

上面根据主键删除适合精准的删除操作,但是适用性有限;下面介绍查询删除的方式,将满足查询条件的数据都删除掉

private void deleteByQuery() {
    SolrDataQuery query = new SimpleQuery();
    query.addCriteria(Criteria.where("content").startsWith("新增"));
    solrTemplate.delete("yhh", query);
    solrTemplate.commit("yhh");
}

上面提供了一个简单的查询条件,删除content内容以新增开头的文档,至于查询语句的使用姿势在下一篇介绍Solr的查询姿势时详细说明

3. 测试

接下来测试一下上面的两种case

首先我们提供一个输出所有文档的方法,用于对比删除前后的数据变化

private void printAll(String tag) {
    System.out.println("\n---------> query all " + tag + " start <------------\n");
    List<DocDO> list = solrTemplate.query("yhh", new SimpleQuery("*:*").addSort(Sort.by("content_id").ascending()), DocDO.class)
                    .getContent();
    list.forEach(System.out::println);
    System.out.println("\n---------> query all " + tag + " over <------------\n");
}

接下来是方法调用

@Autowired
private SolrTemplate solrTemplate;

public void delete() {
    printAll("init");
    this.deleteById();
    this.deleteByQuery();
    printAll("afterDelete");
}

输出结果如下,id为4,5,6的都被删除了

---------> query all init start <------------

DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578988256, publishAt=1578988256)
DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)
DocDO(id=5, contentId=5, title=addBatchByBean - 1, content=新增一个测试文档, type=1, createAt=1578988256, publishAt=1578988256)
DocDO(id=6, contentId=6, title=addBatchByBean - 2, content=新增又一个测试文档, type=1, createAt=1578988256, publishAt=1578988256)

---------> query all init over <------------


---------> query all afterDelete start <------------

DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578988256, publishAt=1578988256)

---------> query all afterDelete over <------------

II. 其他

0. 系列博文&项目源码

系列博文

项目源码

1.4 - 4.Solr查询使用姿势小结

接下来进入solr CURD的第四篇,查询的使用姿势介绍,本文将主要包括以下知识点

  • 基本的查询操作
  • fq查询
  • fl指定字段查询
  • 比较/范围
  • 排序
  • 分页
  • 分组

I. 配置

在介绍demo之前,需要先安装solr环境,搭建SpringBoot项目工程,具体的环境搭建过程不细说,推荐参考文档

application.yml 配置文件中红,指定solr的域名

spring:
  data:
    solr:
      host: http://127.0.0.1:8983/solr

然后在solr中,写入一些数据,供我们查询使用,可以通过控制台的方式写入,也可以通过190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势 这篇文档的case添加

初始化solr文档内容如下

{
  "id":"1",
  "content_id":1,
  "title":"一灰灰blog",
  "content":"这是一灰灰blog的内容",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655609540674060288},
{
  "id":"2",
  "content_id":2,
  "title":"一灰灰",
  "content":"这是一灰灰的内容",
  "type":1,
  "create_at":1578912072,
  "publish_at":1578912072,
  "_version_":1655609550229733376},
{
  "id":"3",
  "content_id":3,
  "title":"solrTemplate 修改之后!!!",
  "create_at":1578993153,
  "publish_at":1578993153,
  "type":0,
  "_version_":1655694325261008896},
{
  "id":"4",
  "content_id":4,
  "type":1,
  "create_at":0,
  "publish_at":0,
  "_version_":1655694325422489600},
{
  "id":"5",
  "content_id":5,
  "title":"addBatchByBean - 1",
  "content":"新增一个测试文档",
  "type":1,
  "create_at":1578993153,
  "publish_at":1578993153,
  "_version_":1655694325129936896},
{
  "id":"6",
  "content_id":6,
  "title":"addBatchByBean - 2",
  "content":"新增又一个测试文档",
  "type":1,
  "create_at":1578993153,
  "publish_at":1578993153,
  "_version_":1655694325136228352
}

II. 查询

solr文档对应的POJO如下,(注意solr中的主键id为string类型,下面定义中用的是Integer,推荐与solr的数据类型保持一致)

@Data
public class DocDO implements Serializable {
    private static final long serialVersionUID = 7245059137561820707L;
    @Id
    @Field("id")
    private Integer id;
    @Field("content_id")
    private Integer contentId;
    @Field("title")
    private String title;
    @Field("content")
    private String content;
    @Field("type")
    private Integer type;
    @Field("create_at")
    private Long createAt;
    @Field("publish_at")
    private Long publishAt;
}

1. 主键查询

支持单个查询和批量查询,三个参数,第一个为需要查询的Collection, 第二个为id/id集合,第三个为返回的数据类型

private void queryById() {
    DocDO ans = solrTemplate.getById("yhh", 1, DocDO.class).get();
    System.out.println("queryById: " + ans);

    Collection<DocDO> list = solrTemplate.getByIds("yhh", Arrays.asList(1, 2), DocDO.class);
    System.out.println("queryByIds: " + list);
}

输出结果如下

queryById: DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
queryByIds: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]

2. 简单查询

比如最简单的根据某个字段进行查询

Query query = new SimpleQuery("title:一灰灰");
Page<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQuery : " + ans.getContent());

直接在SimpleQuery中指定查询条件,上面的case表示查询title为一灰灰的文档

输出结果如下:

simpleQuery : [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]

简单的查询使用上面的姿势ok,当然就是阅读起来不太优雅;推荐另外一种基于Criteria的查询条件构建方式

  • 如果看过之前的mongodb系列教程,可以看到monodb的查询条件也用到了Criteria来拼装,但是请注意这两个并不是一个东西
query = new SimpleQuery();
// 查询内容中包含一灰灰的文档
query.addCriteria(new Criteria("content").contains("一灰灰"));

ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQuery : " + ans.getContent());

输出结果如下

simpleQuery : [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]

Criteria可以构建复杂的且阅读友好的查询条件,后面会有具体的演示,这里给出一个多条件查询的case

// 多个查询条件
query = new SimpleQuery();
query.addCriteria(Criteria.where("title").contains("一灰灰").and("content_id").lessThan(2));
ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("multiQuery: " + ans.getContent());

输出结果如下,在上面的基础上,捞出了contentId小于2的记录

multiQuery: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]

3. fq查询

fq 主要用来快速过滤,配合query进行操作,主要是借助org.springframework.data.solr.core.query.Query#addFilterQuery来添加fq条件

// fq查询
query = new SimpleQuery("content: *一灰灰*");
query.addFilterQuery(FilterQuery.filter(Criteria.where("title").contains("blog")));
ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQueryAndFilter: " + ans.getContent());

输出结果如:

simpleQueryAndFilter: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]

4. fl指定查询字段

当我们只关注solr文档中的部分字段时,可以考虑指定fl,只获取所需的字段;通过org.springframework.data.solr.core.query.SimpleQuery#addProjectionOnFields(java.lang.String...)来指定需要返回的字段名

/**
 * 查询指定的字段
 */
private void querySpecialFiled() {
    SimpleQuery query = new SimpleQuery();
    query.addCriteria(Criteria.where("content_id").lessThanEqual(2));
    // fl 查询
    query.addProjectionOnFields("id", "title", "content");

    List<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
    System.out.println("querySpecialField: " + ans);
}

输出结果如下

querySpecialField: [DocDO(id=1, contentId=null, title=一灰灰blog, content=这是一灰灰blog的内容, type=null, createAt=null, publishAt=null), DocDO(id=2, contentId=null, title=一灰灰, content=这是一灰灰的内容, type=null, createAt=null, publishAt=null)]

请注意,我们指定了只需要返回id, title, content,所以返回的DO中其他的成员为null

5. 范围查询

针对数字类型,支持范围查询,比如上面给出Criteria.where("content_id").lessThanEqual(2),表示查询content_id小于2的记录,下面给出一个between的查询

/**
 * 范围查询
 */
private void queryRange() {
    Query query = new SimpleQuery();
    query.addCriteria(Criteria.where("content_id").between(1, 3));
    query.addSort(Sort.by("content_id").ascending());
    List<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
    System.out.println("queryRange: " + ans);
}

输出结果如下,请注意between查询,左右都是闭区间

queryRange: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997659, publishAt=1578997659)]

如果不想要闭区间,可以用between的重载方法

query = new SimpleQuery();
// 两个false,分表表示不包含下界 上界
query.addCriteria(Criteria.where("content_id").between(1, 3, false, false));
query.addSort(Sort.by("content_id").ascending());
ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
System.out.println("queryRange: " + ans);

输出结果如

queryRange: [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]

6. 排序

上面的case中,已经用到了排序,主要是Sort来指定排序字段以及排序的方式;因为id在solr中实际上是字符串格式,所以如果用id进行排序时,实际上是根据字符串的排序规则来的(虽然我们的POJO中id为int类型)

/**
 * 查询并排序
 */
private void queryAndSort() {
    // 排序
    Query query = new SimpleQuery();
    query.addCriteria(new Criteria("content").contains("一灰灰"));
    // 倒排
    query.addSort(Sort.by("content_id").descending());
    Page<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class);
    System.out.println("queryAndSort: " + ans.getContent());
}

输出结果如下

queryAndSort: [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]

7. 分页查询

分页查询比较常见,特别是当数据量比较大时,请一定记得,添加分页条件

一个查询case如下,查询所有的数据,并制定了分页条件,查询第二条和第三条数据(计数从0开始)

/**
 * 分页
 */
private void queryPageSize() {
    Query query = new SimpleQuery("*:*");
    query.addSort(Sort.by("content_id").ascending());
    // 指定偏移量,从0开始
    query.setOffset(2L);
    // 查询的size数量
    query.setRows(2);
    Page<DocDO> ans = solrTemplate.queryForPage("yhh", query, DocDO.class);

    // 文档数量
    long totalDocNum = ans.getTotalElements();
    List<DocDO> docList = ans.getContent();
    System.out.println("queryPageSize:  totalDocNum=" + totalDocNum + " docList=" + docList);
}

在返回结果中,查了返回查询的文档之外,还会给出满足条件的文档数量,可以通过Page#getTotalElements获取,

上面case输出结果如下

queryPageSize:  totalDocNum=6 docList=[DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997946, publishAt=1578997946), DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)]

8. 分组查询

分组和前面的查询有一点区别,主要在于结果的处理,以及分组参数必须指定分页信息

/**
 * 分组查询
 */
private void queryGroup() {
    Query query = new SimpleQuery("*:*");
    // 请注意,分组查询,必须指定 offset/limit, 否则会抛异常,Pageable must not be null!
    GroupOptions groupOptions = new GroupOptions().addGroupByField("type").setOffset(0).setLimit(10);
    query.setGroupOptions(groupOptions);

    GroupPage<DocDO> ans = solrTemplate.queryForGroupPage("yhh", query, DocDO.class);
    GroupResult<DocDO> groupResult = ans.getGroupResult("type");

    Page<GroupEntry<DocDO>> entries = groupResult.getGroupEntries();
    System.out.println("============ query for group ============ ");
    for (GroupEntry<DocDO> sub : entries) {
        // type 的具体值
        String groupValue = sub.getGroupValue();
        Page<DocDO> contentList = sub.getResult();
        System.out.println("queryGroup v=" + groupValue + " content=" + contentList.getContent());
    }
    System.out.println("============ query for group ============ ");
}

上面的case虽然比较简单,但是有几点需要注意, 特别是返回结果的获取,包装层级有点深

  • GroupOptions:
    • 必须指定offset/limit,当两个条件都没有时会抛异常
    • 只指定offset时,limit默认为1
    • 只指定limit时,offset默认为0
  • 结果处理
    • GroupPage#getGroupResult(field) 获取分组内容,其中field为指定分组的成员
    • 遍历GroupResult#getGroupEntries,获取每个分组对应的文档列表

输出结果如下

============ query for group ============ 
queryGroup v=1 content=[DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=5, contentId=5, title=addBatchByBean - 1, content=新增一个测试文档, type=1, createAt=1578997946, publishAt=1578997946), DocDO(id=6, contentId=6, title=addBatchByBean - 2, content=新增又一个测试文档, type=1, createAt=1578997946, publishAt=1578997946), DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)]
queryGroup v=0 content=[DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997946, publishAt=1578997946)]
============ query for group ============

III. 其他

0. 系列博文&工程源码

系列博文

工程源码

1.5 - 5.Solr身份认证与授权更新异常解决方案

之前介绍solr的教程中,solr没有开启权限校验,所有的操作都是无需鉴权;当时提到,如果solr开启了权限校验,改一下solr的host,带上用户名/密码即可,然而真实情况却并不太一样,查询ok,涉及到修改的操作,则会抛异常

本文将带你了解一下,这到底是个什么鬼畜现象

I. Solr配置用户登录

1. 安装

之前的solr系列教程中,通过docker安装的solr,下面的步骤也是直接针对docker中的solr进行配置,基本步骤一样

具体可以参考: 【搜索系列】Solr环境搭建与简单测试

不想看的同学,直接用下面的命令即可:

docker pull solr
docker run --name my-solr -d -p 8983:8983 -t solr

2. 配置

下面一步一步教你如何设置用户密码,也可以参考博文: 手把手教你 对 solr8 配置用户登录验证

进入实例,注意使用root用户,否则某些操作可能没有权限

docker exec  -u root -it my-solr /bin/bash

创建鉴权文件

vim server/etc/verify.properties

内容如下,格式为 用户名:密码,权限, 一行一个账号

root:123,admin

配置鉴权文件

vim server/contexts/solr-jetty-context.xml

添加下面的内容放在Configure标签内

<Get name="securityHandler">    
   <Set name="loginService">    
           <New class="org.eclipse.jetty.security.HashLoginService">    
                  <Set name="name">verify—name</Set> 
                  <Set name="config"><SystemProperty name="jetty.home" default="."/>/etc/verify.properties</Set>    
           </New>    
   </Set>    
</Get>

修改web.xml

vim server/solr-webapp/webapp/WEB-INF/web.xml

security-constraint标签下面,新增

<login-config>     
		<auth-method>BASIC</auth-method> 
		<!-- 请注意,这个name 和上面的Set标签中的name保持一致 -->
		<realm-name>verify-name</realm-name>   
</login-config>

重启solr,配置生效

docker restart my-solr

II. 场景复现

接下来介绍一下我们的环境

  • springboot: 2.2.1.RELEASE
  • solr: 8.0

1. 项目环境

搭建一个简单的springboot项目,xml依赖如下

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.1.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-solr</artifactId>
    </dependency>
    
    <!-- 请注意,在solr开启登录验证时,这个依赖必须有 -->
    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>
<repositories>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/libs-snapshot-local</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/libs-milestone-local</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
    <repository>
        <id>spring-releases</id>
        <name>Spring Releases</name>
        <url>https://repo.spring.io/libs-release-local</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

对应的配置文件application.yml

spring:
  data:
    solr:
      # 请注意,用户名密码直接写在了url中
      host: http://root:123@127.0.0.1:8983/solr

2. 复现

关于solr的基本操作,如果有疑问的小伙伴可以翻一下我之前的搜索系列博文,满足你的扫盲需求;

核心的solr操作实例如下:

@Data
public class DocDO implements Serializable {
    private static final long serialVersionUID = 7245059137561820707L;
    @Id
    @Field("id")
    private Integer id;
    @Field("content_id")
    private Integer contentId;
    @Field("title")
    private String title;
    @Field("content")
    private String content;
    @Field("type")
    private Integer type;
    @Field("create_at")
    private Long createAt;
    @Field("publish_at")
    private Long publishAt;
}

@Component
public class SolrOperater {

    @Autowired
    private SolrTemplate solrTemplate;


    public void operate() {
        testAddByDoc();
        queryById();
    }

    public void testAddByDoc() {
        SolrInputDocument document = new SolrInputDocument();
        document.addField("id", 999999);
        document.addField("content_id", 3);
        document.addField("title", "testAddByDoc!");
        document.addField("content", "新增哒哒哒");
        document.addField("type", 2);
        document.addField("create_at", System.currentTimeMillis() / 1000);
        document.addField("publish_at", System.currentTimeMillis() / 1000);

        UpdateResponse response = solrTemplate.saveDocument("yhh", document, Duration.ZERO);
        solrTemplate.commit("yhh");
        System.out.println("over:" + response);
    }

    private void queryById() {
        DocDO ans = solrTemplate.getById("yhh", 999999, DocDO.class).get();
        System.out.println("queryById: " + ans);
    }
}

SolrTemplat定义如下

@Configuration
public class SearchAutoConfig {
    @Bean
    @ConditionalOnMissingBean(SolrTemplate.class)
    public SolrTemplate solrTemplate(SolrClient solrClient) {
        return new SolrTemplate(solrClient);
    }
}

开始测试

@SpringBootApplication
public class Application {

    public Application(SolrOperater solrOperater) {
        solrOperater.operate();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

请注意,复现上面的场景时,会发现查询没问题,修改则会抛异常

3. 解决方案

a. 降版本

我之前用solr的时候,也是上面的操作方式,然而并没有出现过这种问题,这就有点蛋疼了;

找之前的项目查看版本,发现之前用的solr-solrj用的是6.6.5,换个版本试一下(默认的版本是8.2.0

<dependency>
    <groupId>org.apache.solr</groupId>
    <artifactId>solr-solrj</artifactId>
    <version>6.6.5</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-solr</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.apache.solr</groupId>
            <artifactId>solr-solrj</artifactId>
        </exclusion>
    </exclusions>
</dependency>

见证奇迹的时刻到了,执行正常了,虽然saveDocument方法的调用标红,但是不影响具体的执行哦

b. SystemDefaultHttpClient

通过一顿debug,单步执行,终于找到为啥6.6.5版本的solr-solrj可以正常操作,而8.2.0却不行(如果想知道这一枯燥的过程,请评论告诉我,否则我也不知道啥时候可以看到😂)

关键的问题就是旧版本的用的是SystemDefaultHttpClient来实现solr的沟通;新版本使用的是InternalHttpClient

那么一个可用的解决方法就是不降版本,改为指定Solr的HttpClient

在配置类中,如下操作:

@Bean
public HttpSolrClient solrClient() {
    HttpClient httpClient = new SystemDefaultHttpClient();
    return new HttpSolrClient.Builder(url).withHttpClient(httpClient).build();
}

然后测试,也是正常执行,输出结果就不截图了,各位小伙伴可以亲自测试一下

c. HttpClient拦截器

关于下面的这段写法,来自: Preemptive Basic authentication with Apache HttpClient 4

上面的方式虽然可以让我们正确操作solr了,但是SystemDefaultHttpClient有一个删除注解,也就是说不建议再直接用它了,那就借鉴它的使用方式,来满足我们的需求,所以可以如下操作

@Value("${spring.data.solr.host}")
private String url;

@Data
public static class UrlDo {
    private String url;

    private String user;
    private String pwd;

    private String host;
    private int port;

    public static UrlDo parse(String url) throws MalformedURLException {
        // http://root:123@127.0.0.1:8983/solr
        URL u = new URL(url);
        UrlDo out = new UrlDo();
        out.setHost(u.getHost());
        out.setPort(u.getPort());

        String userInfo = u.getUserInfo();
        if (!StringUtils.isEmpty(userInfo)) {
            String[] users = org.apache.commons.lang3.StringUtils.split(userInfo, ":");
            out.setUser(users[0]);
            out.setPwd(users[1]);
        }
        out.setUrl(url);
        return out;
    }
}

public class SolrAuthInterceptor implements HttpRequestInterceptor {
    @Override
    public void process(final HttpRequest request, final HttpContext context) {
        AuthState authState = (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);
        if (authState.getAuthScheme() == null) {
            CredentialsProvider credsProvider =
                    (CredentialsProvider) context.getAttribute(HttpClientContext.CREDS_PROVIDER);
            HttpHost targetHost = (HttpHost) context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST);
            AuthScope authScope = new AuthScope(targetHost.getHostName(), targetHost.getPort());
            Credentials creds = credsProvider.getCredentials(authScope);
            authState.update(new BasicScheme(), creds);
        }
    }
}

@Bean
public HttpSolrClient solrClient() throws MalformedURLException {
    UrlDo urlDo = UrlDo.parse(url);
    CredentialsProvider provider = new BasicCredentialsProvider();
    provider.setCredentials(new AuthScope(urlDo.getHost(), urlDo.getPort()),
            new UsernamePasswordCredentials(urlDo.getUser(), urlDo.getPwd()));

    HttpClientBuilder builder = HttpClientBuilder.create();
    // 请注意下面这一行,指定拦截器,用于设置认证信息
    builder.addInterceptorFirst(new SolrAuthInterceptor());
    builder.setDefaultCredentialsProvider(provider);
    CloseableHttpClient httpClient = builder.build();
    return new HttpSolrClient.Builder(url).withHttpClient(httpClient).build();
}

上面的实现有点长,简单的拆解一下

  • UrlDo: 解析solr的url,得到我们需要的host + port + user + password
  • solrClient: 在创建SolrClient bean实例时,指定相应的授权信息
  • SolrAuthInterceptor: 自定义拦截器,更新authState信息

d. SolrRequest

上面的三种方式,适用于利用SolrClient或者SolrTemplate来操作的solr;当然我可以完全抛弃掉它们,直接使用SolrRequest来操作,如下

SolrInputDocument document = new SolrInputDocument();
document.addField("id", 999999);
document.addField("content_id", 3);
document.addField("title", "testAddByDoc!");
document.addField("content", "新增哒哒哒");
document.addField("type", 2);
document.addField("create_at", System.currentTimeMillis() / 1000);
document.addField("publish_at", System.currentTimeMillis() / 1000);

UpdateRequest updateRequest = new UpdateRequest();
updateRequest.setBasicAuthCredentials("root", "123");
updateRequest.add(document);
UpdateResponse response = updateRequest.process(solrClient, "yhh");
updateRequest.commit(solrClient, "yhh");

4. 小结

本篇博文主要是针对需要登录验证的solr更新操作异常时,给出了四种解决方案

  • solr-solrj版本到6.6.0
  • 指定SolrClientHttpClientSystemDefaultHttpClient
  • HttpClient拦截器
  • SolrRequest指定用户名密码

上面虽然给出了解决方法,但是为啥有这个问题呢?

直接通过curl来测试一下更新solr操作,正常返回,并没有问题,那么这个问题到底啥原因,究竟是谁的锅,请敬请期待后续问题定位盖锅定论

II. 其他

0. 系列博文&工程源码

参考博文

系列博文

工程源码

2 - ElasticSearch

SpringBoot整合ElasticSearch,支撑搜索全业务场景

2.1 - 1.ES基本项目搭建

之前一直没有写ES相关的博文,现在开始补课,预计5-6篇博文将es的使用姿势展示给各位小伙伴;本文将作为es结合springboot的第一篇博文,基本项目环境搭建

I. 项目搭建

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
    </dependency>
</dependencies>

2. 配置信息

配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式

elasticsearch:
  host: localhost
  port: 9200
  user: elastic
  pwd: test123
  connTimeout: 3000
  socketTimeout: 5000
  connectionRequestTimeout: 500

说明

上面配置介绍的是一种偏基础的es文档操作姿势,相比较于封装得更好的spring-boot-starter-data-elasticsearch,使用更加灵活

II. SpringBoot结合ES使用

1. RestHighLevelClient 初始化

接下来我们基于RestHighLevelClient来操作es,首先第一步就是需要初始化这实例

@Getter
@Configuration
public class ElasticsearchConfiguration {

    @Value("${elasticsearch.host}")
    private String host;

    @Value("${elasticsearch.port}")
    private int port;

    @Value("${elasticsearch.connTimeout}")
    private int connTimeout;

    @Value("${elasticsearch.socketTimeout}")
    private int socketTimeout;

    @Value("${elasticsearch.connectionRequestTimeout}")
    private int connectionRequestTimeout;

    @Value("${elasticsearch.user}")
    private String user;

    @Value("${elasticsearch.pwd}")
    private String pwd;

    @Bean(destroyMethod = "close", name = "client")
    public RestHighLevelClient initRestClient() {
        RestClientBuilder builder = RestClient.builder(new HttpHost(host, port))
                .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
                        .setConnectTimeout(connTimeout)
                        .setSocketTimeout(socketTimeout)
                        .setConnectionRequestTimeout(connectionRequestTimeout));
        return new RestHighLevelClient(builder);
    }
}

注意上面的实现,用户名 + 密码并没有使用,当es设置了用户名、密码之后,是通过每次请求时,在请求头基于Basic Auth方式进行身份验证的;后面会介绍到

2. 基本使用

我们在本机搭建了一个es用于模拟测试,在上面的配置完之后,就可以直接与es进行交互了

es安装可以参考:

下面是一个简单的使用姿势

@Service
public class EsTest {
    @Autowired
    private RestHighLevelClient client;

    private static String auth;

    public EsTest(ElasticsearchConfiguration elasticsearchConfiguration) {
        auth = Base64Utils.encodeToString((elasticsearchConfiguration.getUser() + ":" + elasticsearchConfiguration.getPwd()).getBytes());
        auth = "Basic " + auth;
    }

    public void testGet() throws Exception {
        // 文档查询
        GetRequest getRequest = new GetRequest("first-index", "_doc", "gvarh3gBF9fSFsHNuO49");
        RequestOptions.Builder requestOptions = RequestOptions.DEFAULT.toBuilder();
        requestOptions.addHeader("Authorization", auth);
        GetResponse getResponse = client.get(getRequest, requestOptions.build());
        if (getResponse.isExists()) {
            String sourceAsString = getResponse.getSourceAsString();
            System.out.println(sourceAsString);
        } else {
            System.out.println("no string!");
        }
    }
}

注意上面的实现,有下面几个重要知识点

身份验证

采用Basic Auth方式进行身份校验,简单来说就是在请求头中添加一个

  • key = Authorization
  • value = "Basic " + base64(user + ":" + pwd)

访问姿势

上面是一个根据id查询文档的实例,简单可以理解为三步

  • 创建:XxRequest
  • 添加请求头:RequestOptions.Builder.addHeader
  • 执行: client.get(xxRequest, RequestOptions)

III. 不能错过的源码和相关知识点

0. 项目

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

2.2 - 2.ES文档基本操作CURD实例演示

本文将作为es系列第二篇,在前文项目搭建的基础上,先来看一下es的基本操作姿势,如何实现CURD

I. 项目搭建

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
    </dependency>
</dependencies>

2. 配置信息

配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式

elasticsearch:
  host: localhost
  port: 9200
  user: elastic
  pwd: test123
  connTimeout: 3000
  socketTimeout: 5000
  connectionRequestTimeout: 500

II. CURD实例

1. 配置

注意,本文介绍的es是添加了权限验证,因此我们在于es进行交互时,需要在请求头中携带验证信息,注意下面的实现姿势

读取配置,初始化RestHighLevelClient,和前文介绍的差不多

@Getter
@Configuration
public class ElasticsearchConfiguration {

    @Value("${elasticsearch.host}")
    private String host;

    @Value("${elasticsearch.port}")
    private int port;

    @Value("${elasticsearch.connTimeout}")
    private int connTimeout;

    @Value("${elasticsearch.socketTimeout}")
    private int socketTimeout;

    @Value("${elasticsearch.connectionRequestTimeout}")
    private int connectionRequestTimeout;

    @Value("${elasticsearch.user}")
    private String user;

    @Value("${elasticsearch.pwd}")
    private String pwd;

    @Bean(destroyMethod = "close", name = "client")
    public RestHighLevelClient initRestClient() {
        RestClientBuilder builder = RestClient.builder(new HttpHost(host, port))
                .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
                        .setConnectTimeout(connTimeout)
                        .setSocketTimeout(socketTimeout)
                        .setConnectionRequestTimeout(connectionRequestTimeout));
        return new RestHighLevelClient(builder);
    }

    @Bean
    public RequestOptions requestOptions() {
        String auth = "Basic " + Base64Utils.encodeToString((user + ":" + pwd).getBytes());
        RequestOptions.Builder build = RequestOptions.DEFAULT.toBuilder();
        build.addHeader("Authorization", auth);
        return build.build();
    }
}

2. 添加数据

@Component
public class BasicCurdDemo {
    @Autowired
    private RestHighLevelClient client;
    @Autowired
    private RequestOptions requestOptions;

    private String TEST_ID = "11123-33345-66543-55231";

    /**
     * 新增数据
     */
    public void addDoc(String indexName, Object obj, String id) throws IOException {
        // 指定索引
        IndexRequest request = new IndexRequest(indexName);
        request.type("_doc");
        // 文档内容,source传参,第一种时按照 fieldName, fieldValue 成对的方式传入;下面是采用json串 + 指定ContentType的方式传入
        request.source(JSON.toJSONString(obj), XContentType.JSON);
        // 指定特殊的id,不指定时自动生成id
        request.id(id);
        IndexResponse response = client.index(request, requestOptions);
        System.out.println("添加数据返回结果: " + response.toString());
    }
}

添加数据,注意是利用 IndexRequest 来构建请求对象,添加文档时有几个注意事项

  • request.source() : 具体需要上传的文档,就是通过它挂上去的,我们这里采用的是json方式
  • request.id(): 如果上传的文档需要指定id,则可以使用它;若未指定,则表明自动生成id

发起请求: client.index()

3. 查询数据

这里先介绍一个基础的根据id进行查询的实例case,更多的查询姿势后面会详细介绍

/**
 * 查询结果
 *
 * @param indexName
 * @param id
 * @throws Exception
 */
public void get(String indexName, String id) throws IOException {
    GetRequest getRequest = new GetRequest(indexName, "_doc", id);
    GetResponse response = client.get(getRequest, requestOptions);
    System.out.println("查询结果:" + response.toString());
}

3. 增量更新数据

根据主键进行更新文档,如下

/**
 * 更新文档,根据id进行更新,增量更新,存在的字段,覆盖;新增的字段,插入;旧字段,保留
 *
 * @param indexName
 * @param docId
 * @param obj
 * @throws IOException
 */
public void updateDoc(String indexName, String docId, Object obj) throws IOException {
    UpdateRequest updateRequest = new UpdateRequest();
    updateRequest.index(indexName);
    updateRequest.type("_doc");
    updateRequest.id(docId);
    // 设置数据
    updateRequest.doc(JSON.toJSONString(obj), XContentType.JSON);

    UpdateResponse response = client.update(updateRequest, requestOptions);
    System.out.println("更新数据返回:" + response.toString());
}

注意

  • 上面的实现属于增量更新策略
  • 即:新传的文档,若key之前已经存在,则覆盖更新;若之前不存在,则插入;之前文档中未被覆盖的数据依然保留

4. 全量更新

另外一个根据条件进行更新的使用case如下

/**
 * 条件更新
 *
 * @param indexName
 * @param query
 * @param data
 * @throws IOException
 */
public void updateByCondition(String indexName, Map<String, String> query, Map<String, Object> data) throws IOException {
    UpdateByQueryRequest updateRequest = new UpdateByQueryRequest(indexName);
    for (Map.Entry<String, String> entry : query.entrySet()) {
        QueryBuilder queryBuilder = new TermQueryBuilder(entry.getKey(), entry.getValue());
        updateRequest.setQuery(queryBuilder);
    }

    // 更新值脚本,精确的更新方式
    // ctx._source['xx'].add('新增字段')
    // 条件判定 if(ctx._source.addr == 'hubei') { ctx._source.addr = 'wuhan';}
    String source = "ctx._source.name='1hui';";
    Script script = new Script(source);
    updateRequest.setScript(script);

    BulkByScrollResponse response = client.updateByQuery(updateRequest, requestOptions);
    System.out.println("条件更新返回: " + response.toString());
    get(indexName, TEST_ID);
    System.out.println("0---------------------0");

    // 采用全量覆盖式更新,直接使用data中的数据,覆盖之前的文档内容
    source = "ctx._source=params";
    script = new Script(ScriptType.INLINE, "painless", source, data);
    updateRequest.setScript(script);
    response = client.updateByQuery(updateRequest, requestOptions);
    System.out.println("条件更新返回: " + response.toString());
    get(indexName, TEST_ID);
}

5. 删除数据

直接根据id进行删除

/**
 * 根据id进行删除
 *
 * @param indexName
 * @param id
 * @throws IOException
 */
public void delete(String indexName, String id) throws IOException {
    DeleteRequest deleteRequest = new DeleteRequest(indexName);
    deleteRequest.type("_doc");
    deleteRequest.id(id);

    DeleteResponse response = client.delete(deleteRequest, requestOptions);
    System.out.println("删除后返回" + response.toString());
}

6. 条件删除数据

根据条件进行匹配删除

/**
 * 条件删除
 *
 * @param indexName
 * @param query
 * @throws IOException
 */
public void deleteByQuery(String indexName, Map<String, String> query) throws IOException {
    DeleteByQueryRequest request = new DeleteByQueryRequest(indexName);
    request.types("_doc");
    for (Map.Entry<String, String> entry : query.entrySet()) {
        QueryBuilder queryBuilder = new TermQueryBuilder(entry.getKey(), entry.getValue());
        request.setQuery(queryBuilder);
    }
    BulkByScrollResponse response = client.deleteByQuery(request, requestOptions);
    System.out.println("条件删除:" + response.toString());
    get(indexName, TEST_ID);
}

7. 测试case

写一个测试demo,将上面的case都跑一遍

public void testOperate() throws IOException {
    String index = "basic_demo";
    Map<String, Object> doc = newMap("name", "一灰灰", "age", 10, "skills", Arrays.asList("java", "python"));
    // 新增
    addDoc(index, doc, TEST_ID);
    // 查询
    get(index, TEST_ID);
    // 更新
    doc.clear();
    doc.put("name", "一灰灰blog");
    doc.put("addr", "hubei");
    updateDoc(index, TEST_ID, doc);
    get(index, TEST_ID);

    updateByCondition(index, newMap("addr", "hubei"), newMap("name", "yihuihui", "site", "https://hhui.top"));
    get(index, TEST_ID);

    // 删除文档
    delete(index, TEST_ID);
}

public <K, V> Map<K, V> newMap(K k, V v, Object... kv) {
    Map<K, V> map = new HashMap<>();
    map.put(k, v);
    for (int i = 0; i < kv.length; i += 2) {
        map.put((K) kv[i], (V) kv[i + 1]);
    }
    return map;
}

输出如下

# 1. 添加数据
添加数据返回结果: IndexResponse[index=basic_demo,type=_doc,id=11123-33345-66543-55231,version=1,result=created,seqNo=34,primaryTerm=4,shards={"total":2,"successful":1,"failed":0}]

# 2. 查询数据
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":1,"_seq_no":34,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"一灰灰","age":10}}

# 3. 增量更新
2022-03-28 19:56:08.781  WARN 18332 --- [/O dispatcher 1] org.elasticsearch.client.RestClient      : request [POST http://localhost:9200/basic_demo/_doc/11123-33345-66543-55231/_update?timeout=1m] returned 1 warnings: [299 Elasticsearch-7.12.0-78722783c38caa25a70982b5b042074cde5d3b3a "[types removal] Specifying types in document update requests is deprecated, use the endpoint /{index}/_update/{id} instead."]
更新数据返回:UpdateResponse[index=basic_demo,type=_doc,id=11123-33345-66543-55231,version=2,seqNo=35,primaryTerm=4,result=updated,shards=ShardInfo{total=2, successful=1, failures=[]}]
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":2,"_seq_no":35,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"一灰灰blog","age":10,"addr":"hubei"}}

# 4. 全量条件更新
条件更新返回: BulkByScrollResponse[took=970ms,timed_out=false,sliceId=null,updated=1,created=0,deleted=0,batches=1,versionConflicts=0,noops=0,retries=0,throttledUntil=0s,bulk_failures=[],search_failures=[]]
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":3,"_seq_no":36,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"1hui","addr":"hubei","age":10}}

III. 不能错过的源码和相关知识点

0. 项目

系列博文

源码

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

2.3 - 3.ES查询常用实例演示

本文将作为es系列第三篇,结合常见的实例,来演示下如何通过RestHighLevelClient来实现es的各种查询支持

I. 项目搭建

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
    </dependency>
</dependencies>

2. 配置信息

配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式

elasticsearch:
  host: localhost
  port: 9200
  user: elastic
  pwd: test123
  connTimeout: 3000
  socketTimeout: 5000
  connectionRequestTimeout: 500

II. 实例演示

0. 准备

在开始之前,先准备插入几条数据,这里会借助上一篇CURD博文中的插入接口

在开始之前就准备两条数据

@Component
public class TermQueryDemo {
    private BasicCurdDemo basicCurdDemo;
    @Autowired
    private RestHighLevelClient client;
    @Autowired
    private RequestOptions requestOptions;

    private String TEST_ID = "11123-33345-66543-55231";
    private String TEST_ID_2 = "11123-33345-66543-55232";

    private String index = "term-demo";

    public TermQueryDemo(BasicCurdDemo basicCurdDemo) throws IOException {
        this.basicCurdDemo = basicCurdDemo;
        Map<String, Object> doc = newMap("name", "一灰灰", "age", 10, "skills", Arrays.asList("java", "python"), "site", "blog.hhui.top");
        basicCurdDemo.addDoc(index, doc, TEST_ID);
        doc = newMap("name", "二灰灰", "age", 16, "skills", Arrays.asList("js", "html"));
        basicCurdDemo.addDoc(index, doc, TEST_ID_2);
    }

    @PreDestroy
    public void remove() throws IOException {
        basicCurdDemo.delete(index, TEST_ID);
        basicCurdDemo.delete(index, TEST_ID_2);
    }
}

1. 全量查询

即查询所有的文档,如借助kibanan的控制台,发起的请求形如

GET index/_search
{
  "query": {
    "match_all": {}
  }
}

于此对应的java实现如下

/**
 * 全量查询
 *
 * @throws IOException
 */
private void queryAll() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    // 查询所有的文档
    searchSourceBuilder.query(QueryBuilders.matchAllQuery());
    searchRequest.source(searchSourceBuilder);

    SearchResponse searchResponse = client.search(searchRequest, requestOptions);
    System.out.println("mathAll: " + searchResponse.toString());
}

注意上面的实现:

  • 初始化SearchRequest实例,用于构建请求相关数据
  • SearchSourceBuilder 来填充查询条件
  • client.search(searchRequest, requestOptions) 执行查询请求,第二个参数为请求参数,这里主要是设置请求时的权限验证信息

通常来说,实际的业务场景中,不太可能出现上面这种没有任何限制的查全量数据,即便真的有查全量数据的case,更常见的是分页查询,如下

private void queryAll() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    int page = 1;
    //每页记录数
    int size = 2;
    //计算出记录起始下标
    int from = (page - 1) * size;
    //起始记录下标,从0开始
    searchSourceBuilder.from(from);
    //每页显示的记录数
    searchSourceBuilder.size(size);
    // 根据age字段进行倒排
    searchSourceBuilder.sort(new FieldSortBuilder("age").order(SortOrder.DESC));
    // 查询所有的文档
    searchSourceBuilder.query(QueryBuilders.matchAllQuery());
    searchRequest.source(searchSourceBuilder);

    SearchResponse searchResponse = client.search(searchRequest, requestOptions);
    System.out.println("mathAll: " + searchResponse.toString());
}

2. 根据Field值精确查询

即es中常说的term查询,具体实现如下

/**
 * term精确查询
 *
 * @throws IOException
 */
private void term() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    // termQuery: 精确查询
    // SpanTermQuery: 词距查询
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.termQuery("site", "blog.hhui.top"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("term: " + response.toString());
}

从上面的实现也可以看出,查询的套路没啥区别,无非就是SearchSourceBuilder中的参数构造不一样;上面主要通过

  • QueryBuilders.termQuery("site", "blog.hhui.top") 来构建 term的查询条件,表明查询 site=blog.hhui.top 的文档

中文查询不到问题

在我们实际使用过程中,如果value为中文,在查询时,可能会遇到命名有对应的数据,但是就查不到,主要原因就在于分词,如对于中文的查询,可以考虑下面这种方式

private void term2() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    // 对于中文查询,需要注意分词的场景, 如果直接使用 "name : 一灰灰" 的方式进行查询,则啥也不会返回
    // elasticsearch 里默认的IK分词器是会将每一个中文都进行了分词的切割,所以你直接想查一整个词,或者一整句话是无返回结果的。
    // 在此种情况下,我们可以通过指定 keyword 的方式来处理, 设置关键词搜索(不进行分词)
    searchSourceBuilder.query(QueryBuilders.termQuery("name.keyword", "一灰灰"));

    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("term2: " + response.toString());
}

3. Field值in查询

另外一个常见的就是多值查询,也就是我们常说的 field in (val1, val2...),这个对应的就是es中的terms查询

/**
 * 相当于in查询
 * {"terms": { "name": ["一灰灰", "二灰灰] }}
 *
 * @throws IOException
 */
private void multTerm() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.termsQuery("name.keyword", "一灰灰", "二灰灰"));

    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("term: " + response.toString());
}

4. 范围查询

对于数值类型的Field,同样是支持比较、范围查询的,对应的是es中 range

/**
 * 范围查询
 * { "range": { "age": { "gt":8, "lt": 12 } }}
 *
 * @throws IOException
 */
private void range() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.rangeQuery("age").gt(8).lt(12));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("range: " + response.toString());
}

注意上面的查询有条件

  • QueryBuilders.rangeQuery("age").gt(8).lt(12)
  • 表示查询 age > 8 && age < 12
  • gte: 表示 >=
  • lte: 表示 <=

5. Field是否存在查询

es不同于mysql的在于它的field可以动态新增,当我们希望查询包含某个字段的文档时,可以考虑 exists

/**
 * 根据字段是否存在查询
 *
 * @throws IOException
 */
private void exists() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.existsQuery("site"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("exists: " + response.toString());
}

6. 模糊查询

es作为搜索引擎,更常见的是模糊匹配,比如match查询

/**
   * 根据字段匹配查询
   *
   * @throws IOException
   */
  private void match() throws IOException {
      SearchRequest searchRequest = new SearchRequest(index);
      searchRequest.types("_doc");

      SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
      searchSourceBuilder.query(QueryBuilders.matchQuery("name", "灰"));
      searchRequest.source(searchSourceBuilder);
      SearchResponse response = client.search(searchRequest, requestOptions);
      System.out.println("matchQuery: " + response.toString());
  }

多Field中进行查询

/**
 * 多字段中查询
 *
 * @throws IOException
 */
private void multiMatch() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.multiMatchQuery("灰", "name", "site"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("multiMatchQuery: " + response.toString());
}

在es的语法支持中,除了match,还有一个wildcard,可以使用?来代指单字符,*来代指0..n字符

/**
 * 模糊查询 ? 单字符  * 0..n字符
 *
 * @throws IOException
 */
private void wild() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.wildcardQuery("site", "*top"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("wildcard: " + response.toString());
}

7. 正则匹配

private void regexp() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.regexpQuery("site", ".*hhui.*"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("regexpQuery: " + response.toString());
}

8. 前缀查询

private void prefix() throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    searchRequest.types("_doc");

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    searchSourceBuilder.query(QueryBuilders.prefixQuery("site", "blog"));
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, requestOptions);
    System.out.println("prefixQuery: " + response.toString());
}

9.小结

本文虽然介绍了一些常见的查询case,但注意并不仅仅只有这些,比如

  • 查询指定Feild的内容
  • 排序
  • 分组聚合
  • 多查询条件组合:and/or
  • 高亮

更多的使用实例,敬请期待…,欢迎感兴趣的小伙伴,点赞收藏评论一波😝

III. 不能错过的源码和相关知识点

0. 项目

系列博文

源码

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog