在一般的项目开发中我们通常通过数据库来操作数据。在面临超大数据量的时,尽管通过 优化 sql分库分表 等操作能够提升查询效率,但是就目前来说终究还是有些差强人意。而 ElasticSearch 全文搜索引擎在大数据量查询方面就显得尤为突出了。

阅读本文前,建议先花半个小时时间对 ElasticSearch 做个简单的了解。

1. 版本关系


Spring Boot 提供了对 ElasticSearch 的支持,由于 ElasticSearch 更新速度较快,Spring Boot 也无法及时做到相应的更新。在 Spring Boot 官网上也只出了对应的版本关系。

Spring Data Release TrainSpring Data ElasticsearchElasticsearchSpring Boot
Moore3.2.x6.8.42.2.x
Lovelace3.1.x6.2.22.1.x
Kay3.0.x5.5.02.0.x
Ingalls2.1.x2.4.01.5.x

在构建项目时需要注意版本对应关系,避免出现问题。

2. 添加依赖


当前项目Spring Boot 是最新的 2.2.6.RELEASE 版本,ElasticSearch 环境是 6.8.1 版本。

Spring Boot 提供了 spring-boot-starter-data-elasticsearch 组件,对 ElasticSearch 的功能进行了封装,我们直接在项目中引入依赖:

<!-- Elastic Search Data-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

3. 添加配置


引入依赖之后,在 application.yml 中添加 ElasticSearch 集群配置:

spring:
  data:
    elasticsearch:
      cluster-name: es-cluster
      cluster-nodes: localhost:9300,localhost:9301

从配置文件中可以看出,这里我们配置的 ES 集群中包含两个节点。接下来我们就可以使用 ElasticSearch 进行 CRUD 操作了。

4. 使用 ElasticSearch

做好准备工作后,我们进入正式环节。

首先,创建实体类

@Document(indexName = "spring-elastic-demo", type = "employee", shards = 3, replicas = 1, refreshInterval = "-1")
public class Employee implements Serializable {

    private static final long serialVersionUID = 4115663818759958065L;
  
    @Id
    private Integer id;

    @Field(type = FieldType.Text)
    private String firstName;

    @Field(type = FieldType.Text)
    private String lastName;

    @Field(type = FieldType.Integer)
    private Integer age;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String about;

    @Field(type = FieldType.Text)
    private String[] interest;

// 省略setter、getter
}

可以发现我们在类以及字段上添加了一些注解:

  • @Document:了解过 ElasticSearch 的朋友肯定知道,在 ElasticSearch 中一条数据通常被称为一个文档。@Document 的功能也就不言而喻了。
  • @Id:标识在 ES 中代表文档 id 的字段。
  • @Field:标识在 ES 中文档内容中的字段。

其实一般我们只需要添加 @Document 注解即可,Spring Boot 会自动将字段做好映射关系,并默认允许分词。

@Document 常用注解属性:

  • indexName:索引名称。
  • type:类型。
  • shards:主分片的个数。
  • replicas:每个主分片拥有副分片的个数。
  • refreshInterval:刷新间隔时间(-1 表示不刷新)。

@FileId 常用注解属性:

  • type:FiledType 枚举对象,用于指定字段的数据类型。
  • format:DateFormat 枚举对象,用于指定代表日期字段的格式化方式。
  • analyzer:指定分词器,这里指定的是中文分词器 ik_max_word。

这样创建的实体类就可以与 ES 中的文档产生映射关系了。

编写 Repository

Spring Boot 中提供了一个名为 ElasticSearchRepository 的接口,可以直接通过它来操作 ES 文档。

ElasticSearchRepository 继承结构图:

image.png

由此可见 ElasticSearchRepository 的使用方式与 JpaRepository 的使用方式一样,编写一个 Repository 继承它即可:

public interface EmployeeRepository extends ElasticsearchRepository<Employee, Integer> {
}

同样的也可以自定义一些方法:

 /**
  * 查询 LastName 满足要求的结果, ES 中执行如下语句:
  * GET /spring-elastic-demo/employee/_search
  * {
  *   "query": {
  *     "match": {
  *       "lastName": lastName
  *     }
  *   }
  * }
  * @param lastName lastName 的值
  * @return list
  */
 List<Employee> findAllByLastName(String lastName);
 /**
  * 查询 About 满足要求的结果, About 字段支持分词, ES 中执行如下语句:
  * GET /spring-elastic-demo/employee/_search
  * {
  *   "query": {
  *     "match": {
  *       "about": about
  *     }
  *   }
  * }
  * @param about about 值
  * @return list
  */
 List<Employee> findEmployeesByAbout(String about);
 /**
  * 复合查询 LastName 满足要求且 Age 的值大于 age 的结果集, ES 中执行如下语句:
  * GET /spring-elastic-demo/employee/_search
  * {
  *   "query": {
  *     "bool": {
  *       "must": [
  *         {
  *           "match": {
  *             "lastName": lastName
  *           }
  *         }
  *       ],
  *       "filter": {
  *         "range": {
  *           "age": {
  *             "gt": age
  *           }
  *         }
  *       }
  *     }
  *   }
  * }
  * @param lastName lastName的值
  * @param age age的值
  * @return list
  */
 List<Employee> findEmployeesByLastNameAndAgeAfter(String lastName, Integer age);

这里尤为注意,方法名不是随意取的,其底层需要通过解析方法名来判断其功能,如:findXXXByXX 这种即通过某一个字段进行查询,类似的还有 findDistinctByXXfindTopByXXfindFirstByXX ... ,通过使用 And、Or 进行组合查询。具体这里不展开,有兴趣地自行研究。

功能测试

这里只测试查询功能,毕竟查询才是 ElasticSearch 的亮点。

根据 id 查询

@Test
public void testFindById() {
    Employee employee = employeeRepository.findById(1).orElse(null);
    System.out.println(employee);
}
Employee{id=1, firstName='John', lastName='Smith', age=25, about='I love to go rock climbing', interest=[sports, music]}

查询所有

@Test
public void testFindAll() {
    Page<Employee> employeePage = employeeRepository.findAll(PageRequest.of(0, 10));
    List<Employee> employees = employeePage.getContent();
    employees.forEach(System.out::println);
}
Employee{id=2, firstName='Jane', lastName='Smith', age=32, about='I like to collect rock albums', interest=[music]}
Employee{id=1, firstName='John', lastName='Smith', age=25, about='I love to go rock climbing', interest=[sports, music]}
Employee{id=3, firstName='Douglas', lastName='Fir', age=35, about='I like to build cabinets', interest=[forestry]}

这里使用的 findAll 方法继承自 PagingAndSortingRepository 的重载方法。源码如下:

@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {

	/**
	 * Returns all entities sorted by the given options.
	 *
	 * @param sort
	 * @return all entities sorted by the given options
	 */
	Iterable<T> findAll(Sort sort);

	/**
	 * Returns a {@link Page} of entities meeting the paging restriction provided in the {@code Pageable} object.
	 *
	 * @param pageable
	 * @return a page of entities
	 */
	Page<T> findAll(Pageable pageable);
}

可以传入 Pageable 对象来指定分页参数,传入 Sort 对象来指定排序规则。

自定义方法 findAllByLastName :

@Test
public void testFindAllByLastName() {
    String lastName = "Smith";
    List<Employee> employees = employeeRepository.findAllByLastName(lastName);
    if (CollectionUtils.isEmpty(employees)) {
        return;
    }
    employees.forEach(System.out::println);
}
Employee{id=2, firstName='Jane', lastName='Smith', age=32, about='I like to collect rock albums', interest=[music]}
Employee{id=1, firstName='John', lastName='Smith', age=25, about='I love to go rock climbing', interest=[sports, music]}

自定义方法 findEmployeesByAbout

@Test
public void testFindEmployeesByAbout() {
    String about = "rock";
    List<Employee> employees = employeeRepository.findEmployeesByAbout(about);
    if (CollectionUtils.isEmpty(employees)) {
        return;
    }
    employees.forEach(System.out::println);
}
Employee{id=2, firstName='Jane', lastName='Smith', age=32, about='I like to collect rock albums', interest=[music]}
Employee{id=1, firstName='John', lastName='Smith', age=25, about='I love to go rock climbing', interest=[sports, music]}

自定义方法 findEmployeesByLastNameAndAgeAfter

@Test
public void testFindEmployeesByLastNameAndAgeAfter() {
    String lastName = "Smith";
    Integer age = 30;
    List<Employee> employees = employeeRepository.findEmployeesByLastNameAndAgeAfter(lastName, age)
    if (CollectionUtils.isEmpty(employees)) {
        return;
    }
    employees.forEach(System.out::println);
}
Employee{id=2, firstName='Jane', lastName='Smith', age=32, about='I like to collect rock albums', interest=[music]}

5. ElasticSearch Client

ElasticSearch 提供了很多客户端,在 Java 环境中主要使用两种客户端与 ElasticSearch 进行交互:

  • Transport Client: 使用 transport 端口 9300
  • RestClient:RestFul 风格的访问方式

细心的同学可能已经发现了,我们上面配置的端口是 9300、9301,也就是说 Spring Boot 默认使用的是 Transport Client 方式与 ElasticSearch 进行交互。我们从启动日志中也能看出相关信息:

......
2020-04-25 17:15:15.594  INFO 14240 --- [           main] o.s.d.e.c.TransportClientFactoryBean     : Adding transport node : 47.105.106.3:9300
2020-04-25 17:15:16.449  INFO 14240 --- [           main] o.s.d.e.c.TransportClientFactoryBean     : Adding transport node : 47.105.106.3:9301
......

反观 Spring Boot 官方以及 ElasticSearch 官方发布的声明:

image.png

大概就是说 ElasticSearch 7.x 版本已经不推荐使用 TransportClient 了,在 ElasticSearch 8.x 的版本中将移除 TransportClient !并且官方墙裂推荐使用 High Level REST Client

6. 使用 RestClient


在 Spring Boot 中使用 RestClient 的方式与 ElasticSearch 也很简单,毕竟 ElasticSearch 是用 Java 开发的。

为了避免与 spring-boot-starter-data-elasticsearch 产生冲突,我们另起一个项目。

6.1 添加依赖

<elasticsearch.version>6.8.1</elasticsearch.version>
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>${elasticsearch.version}</version>
</dependency>

由于我这里的本地环境是 6.8.1 的 ES,所以引入对应版本的依赖。

elasticsearch-rest-high-level-client 中已经包含 elasticsearch-rest-clientelasticsearch ,所以不用重复添加依赖。

6.2 配置 HighLevelRestClient

创建 ElasticProperties 属性配置类从 application.yml 文件中读取 ElasticSearch 集群配置:

@Component
@ConfigurationProperties(prefix = "elastic")
public class ElasticProperties {

    private List<EsNode> nodes;

    private String username;

    private String password;

    private String schema = "http";

    public static class EsNode {

        /**
         * es 节点名称
         */
        private String name;

        /**
         * 地址
         */
        private String host;
  
        /**
         * 端口
         */
        private int port;

	// 省略setter、getter
    }

    // 省略setter、getter
}

pom 文件中添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

application.yml 中添加自定义配置:

elastic:
  nodes:
    - name: es-node1
      host: 47.105.106.3
      port: 9200

    - name: es-node2
      host: 47.105.106.3
      port: 9201
   
  username:
  password:
  schema: http

创建 ElasticConfig 配置类,向 Spring IOC 容器中注册 RestClientBuilder 对象。

@Configuration
public class ElasticConfig
{
    @Autowired
    private ElasticProperties elasticProperties;

    @Bean
    public RestHighLevelClient highLevelClient() {
        return new RestHighLevelClient(restClientBuilder());
    }

    @Bean
    public RestClientBuilder restClientBuilder()
    {
        return RestClient.builder(makeHttpHost());
    }

    private HttpHost[] makeHttpHost()
    {
        List<HttpHost> httpHosts = new ArrayList<>();
        List<ElasticProperties.EsNode> nodes = elasticProperties.getNodes();
        if (CollectionUtils.isEmpty(nodes)) {
            throw new RuntimeException("nodes must not be null!");
        }
        nodes.forEach(esNode -> httpHosts.add(new HttpHost(esNode.getHost(), esNode.getPort(), elasticProperties.getSchema())));
        return httpHosts.toArray(new HttpHost[nodes.size()]);
    }

}

6.3 封装方法

这里由于方法较多,这里就只贴一个搜索的方法,详情可查看项目源码

@Service
public class ElasticService {

    private static final Logger logger = LoggerFactory.getLogger(ElasticService.class);
  
    static final String AGG_SUFFIX = ".keyword";
  
    @Autowired
    RestHighLevelClient highLevelClient;

    @Autowired
    ObjectMapper objectMapper;

    /**
     * 全文检索,ES 中执行类似如下语句:
     * GET /spring-elastic-rest/Employee/_search
     * {
     * "query": {
     * "multi_match": {
     * "query": "rock Fir",
     * "fields": ["lastName", "about"]
     * }
     * }
     * }
     *
     * @param index    索引名称
     * @param fields   需要检索的字段列表
     * @param keywords 查询关键字
     * @param tClass   对象 class
     * @param <T>      对象泛型
     * @return 对象列表
     */
    public <T> List<T> search(String index, String[] fields, String keywords, Class<T> tClass) {
        SearchRequest searchRequest = new SearchRequest(index);
        SearchSourceBuilder searchSourceBuilder = searchRequest.source();
        searchSourceBuilder.query(QueryBuilders.multiMatchQuery(keywords, fields));
        return processSearch(searchRequest, tClass);
    }
    /**
     * 执行搜索公共方法
     *
     * @param searchRequest SearchRequest 封装对象
     * @param tClass        对象 class
     * @param <T>           对象泛型
     * @return list
     */
    private <T> List<T> processSearch(SearchRequest searchRequest, Class<T> tClass) {
        try {
            SearchResponse response = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
            SearchHit[] hits = response.getHits().getHits();
            if (hits == null || hits.length == 0) {
                logger.info("nothing founded.");
                return null;
            }
            return Arrays.stream(hits).map(SearchHit::getSourceAsMap).map(map ->
            {
                try {
                    return objectMapper.readValue(objectMapper.writeValueAsString(map), tClass);
                } catch (IOException e) {
                    e.printStackTrace();
                    return null;
                }
            }).collect(Collectors.toList());
        } catch (IOException e) {
            logger.error("process search with error, searchRequest: {}", searchRequest);
            return null;
        }
    }
}

测试接口:

@Test
public void testSearch2() {
    String[] fields = {"lastName", "about"};
    List<Employee> employees = elasticService.search(TEST_INDEX, fields, "rock Fir", Employee.class);
    employees = Optional.ofNullable(employees)
            .orElse(new ArrayList<>());
    employees.forEach(System.out::println);
}
Employee{id=3, firstName='Douglas', lastName='Fir', age=35, about='I like to build cabinets', interest=[forestry]}
Employee{id=1, firstName='John', lastName='Smith', age=25, about='I love to go rock climbing', interest=[sports, music]}
Employee{id=2, firstName='Jane', lastName='Smith', age=32, about='I like to collect rock albums', interest=[music]}

RestHighLevelClient 提供了很多方法,在实际开发中可根据需求进行相应的封装。具体使用参照官方 API

7. 后记

Spring Boot 目前提供的是 Transport Client 方式与 ElasticSearch 进行交互,但是在查看 spring-boot-starter-data-elasticsearch 中的源码时发现了一些与 RestHighLevelClient 有关的代码。

package org.springframework.boot.autoconfigure.elasticsearch.rest;

@ConfigurationProperties(prefix = "spring.elasticsearch.rest")
public class RestClientProperties {

	private List<String> uris = new ArrayList<>(Collections.singletonList("http://localhost:9200"));

	private String username;

	private String password;

	// ......
}
@Configuration
@ConditionalOnClass(RestClient.class)
@EnableConfigurationProperties(RestClientProperties.class)
@Import({ RestClientConfigurations.RestClientBuilderConfiguration.class,
		RestClientConfigurations.RestHighLevelClientConfiguration.class,
		RestClientConfigurations.RestClientFallbackConfiguration.class })
public class RestClientAutoConfiguration {

}
class RestClientConfigurations {

	@Configuration
	static class RestClientBuilderConfiguration {

		@Bean
		@ConditionalOnMissingBean
		public RestClientBuilder elasticsearchRestClientBuilder(RestClientProperties properties,
				ObjectProvider<RestClientBuilderCustomizer> builderCustomizers) {
			HttpHost[] hosts = properties.getUris().stream().map(HttpHost::create).toArray(HttpHost[]::new);
			RestClientBuilder builder = RestClient.builder(hosts);
			PropertyMapper map = PropertyMapper.get();
			map.from(properties::getUsername).whenHasText().to((username) -> {
				CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
				Credentials credentials = new UsernamePasswordCredentials(properties.getUsername(),
						properties.getPassword());
				credentialsProvider.setCredentials(AuthScope.ANY, credentials);
				builder.setHttpClientConfigCallback(
						(httpClientBuilder) -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
			});
			builderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
			return builder;
		}

	}
	// ......
}

可见我们可以直接使用 Spring Boot 提供的配置类来创建 RestHighLevelClient,而之后接口的封装依旧是需要我们自己实现。

如果 TransportClient 能够支撑业务,我们直接使用开箱即用的 ElasticSearchRepository 无疑是最佳选择。如果需要处理复杂业务就使用 RestHighLevelClient 封装满足业务需求的接口。

源码地址:


关于作者:NekoChips
本文地址:https://chenyangjie.com.cn/articles/2020/04/25/1587820360963.html
版权声明:本篇所有文章仅用于学习和技术交流,本作品采用 BY-NC-SA 4.0 许可协议,如需转载请注明出处!
许可协议:知识共享许可协议