前端对应的页面如下,可以查询全部,目的地,攻略,游记,用户。

API模块

trip_modules-api父模块下创建子模块trip-search-api,pom文件内容如下:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.swx</groupId>
<artifactId>trip-modules-api</artifactId>
<version>1.0.0</version>
</parent>

<artifactId>trip-search-api</artifactId>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
</dependencies>

</project>

文档实体类

创建ES文档实体类:DestinationEs,首先创建com.swx.search.domain包,在该包下创建文档实体类。

DestinationEs
/**
* 目的地搜索对象
*/
@Getter
@Setter
@Document(indexName = DestinationEs.INDEX_NAME)
public class DestinationEs implements Serializable {
public static final String INDEX_NAME = "destination";

@Id
//@Field 每个文档的字段配置(类型、是否分词、是否存储、分词器 )
@Field(type = FieldType.Long)
private Long id; //目的地id

@Field(type = FieldType.Keyword)
private String name;

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

创建攻略搜索对象

StrategyEs
/**
* 攻略搜索对象
*/
@Getter
@Setter
@Document(indexName = StrategyEs.INDEX_NAME)
public class StrategyEs implements Serializable {

public static final String INDEX_NAME = "strategy";

//@Field 每个文档的字段配置(类型、是否分词、是否存储、分词器 )
@Id
@Field(type = FieldType.Long)
private Long id; //攻略id
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String title; //攻略标题
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String subTitle; //攻略标题
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String summary; //攻略简介
}

创建游记搜索对象

TravelEs
/**
* 游记搜索对象
*/
@Getter
@Setter
@Document(indexName = TravelEs.INDEX_NAME)
public class TravelEs implements Serializable {
public static final String INDEX_NAME = "travel";
//@Field 每个文档的字段配置(类型、是否分词、是否存储、分词器 )
@Id
@Field(type = FieldType.Long)
private Long id; //游记id
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String title; //游记标题
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String summary; //游记简介
}

创建用户搜索对象

UserInfoEs
/**
* 用户搜索对象
*/
@Getter
@Setter
@Document(indexName = UserInfoEs.INDEX_NAME)
public class UserInfoEs implements Serializable {
public static final String INDEX_NAME = "userinfo";
@Id
//@Field 每个文档的字段配置(类型、是否分词、是否存储、分词器 )
@Field(type = FieldType.Long)
private Long id; //用户id
@Field(type = FieldType.Keyword)
private String city;
@Field(analyzer = "ik_max_word", searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String info;
}

微服务模块

trip_modules父模块下创建子模块trip-search-server,pom文件内容如下:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.swx</groupId>
<artifactId>trip-modules</artifactId>
<version>1.0.0</version>
</parent>

<artifactId>trip-search-service</artifactId>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>com.swx</groupId>
<artifactId>trip-search-api</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.swx</groupId>
<artifactId>trip-common-redis</artifactId>
</dependency>
<dependency>
<groupId>com.swx</groupId>
<artifactId>trip-users-api</artifactId>
</dependency>
<dependency>
<groupId>com.swx</groupId>
<artifactId>trip-article-api</artifactId>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.9.4</version>
</dependency>
</dependencies>
</project>

配置文件:bootstrap.yaml,内容如下:

spring:
application:
name: search-service
cloud:
nacos:
server-addr: 124.221.23.47:8848
config:
file-extension: yaml
namespace: trip_cloud_dev
shared-configs:
- data-id: redis-${spring.profiles.active}.yaml
refresh: true
profiles:
active: dev

Nacos配置文件

  • ID:search-service-dev.yaml

  • Group:DEFAULT_GROUP

  • 描述:搜索微服务配置文件

  • 配置内容:

    search-service-dev.yaml
    server:
    port: 8095
    spring:
    cloud:
    nacos:
    server-addr: xxx.xxx.xxx.xxx:8848
    discovery:
    namespace: ${spring.cloud.nacos.config.namespace}
    elasticsearch:
    rest:
    uris: http://xxx.xxx.xxx.xxx:9200

配置网关路由

  • ID:trip-gateway-dev.yaml

  • Group:DEFAULT_GROUP

  • 描述:旅游项目网关配置

  • 配置内容:

    trip-gateway-dev.yaml
    - id: trip_search
    uri: lb://search-service
    predicates:
    - Path=/search/**
    filters:
    - StripPrefix=1

创建启动类:TripSearchApplication,首先创建com.swx.search包,在该包下创建。

TripSearchApplication
@EnableFeignClients
@SpringBootApplication
public class TripSearchApplication {
public static void main(String[] args) {
SpringApplication.run(TripSearchApplication.class, args);
}
}

ElasticsearchService

在包com.swx.search.parser下创建 ElasticsearchTypeParser

传入一个函数,该函数会根据 id 查询数据库,返回一个实体对象

/**
* ES 搜索类型解析器
* 函数式接口: 这个接口必须有一个抽象方法, 并且只能有一个抽象方法
* 使用 lambda 实现函数是接口, 这个 lambda 就可以理解为这个函数式接口的实现类 === 匿名内部类
*/
@FunctionalInterface
public interface ElasticsearchTypeParser<T> {

T parse(Class<T> clazz, String id);
}

在包com.swx.search.utils下创建 BeanUtils

public abstract class BeanUtils {

public static void copyProperties(Object source, Object target) throws BeansException {
copyProperties(source, target, (Class) null, (String[]) null);
}

public static void copyProperties(Object source, Object target, @Nullable Class<?> editable, @Nullable String... ignoreProperties) throws BeansException {
Assert.notNull(source, "Source must not be null");
Assert.notNull(target, "Target must not be null");
Class<?> actualEditable = target.getClass();
if (editable != null) {
if (!editable.isInstance(target)) {
throw new IllegalArgumentException("Target class [" + target.getClass().getName() + "] not assignable to Editable class [" + editable.getName() + "]");
}

actualEditable = editable;
}

PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);
List<String> ignoreList = ignoreProperties != null ? Arrays.asList(ignoreProperties) : null;
PropertyDescriptor[] var7 = targetPds;
int var8 = targetPds.length;

for (int var9 = 0; var9 < var8; ++var9) {
PropertyDescriptor targetPd = var7[var9];
Method writeMethod = targetPd.getWriteMethod();
if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
if (sourcePd != null) {
Method readMethod = sourcePd.getReadMethod();
if (readMethod != null && ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {
try {
if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
readMethod.setAccessible(true);
}

Object value = readMethod.invoke(source);
if (value == null) {
continue;
}

if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
writeMethod.setAccessible(true);
}

writeMethod.invoke(target, value);
} catch (Throwable var15) {
throw new FatalBeanException("Could not copy property '" + targetPd.getName() + "' from source to target", var15);
}
}
}
}
}
}
}

在包com.swx.search.service下创建 ElasticsearchService

ElasticsearchService
import com.swx.common.core.qo.QueryObject;
import com.swx.search.parser.ElasticsearchTypeParser;
import org.springframework.data.domain.Page;

public interface ElasticsearchService {

/**
* 新增方法
* @param entity ES 实体对象
*/
public void save(Object entity);

/**
* 批量新增方法
* @param iterable ES 实体对象集合
*/
public void save(Iterable<?> iterable);

/**
* 删除方法
* @param id 主键
* @param clazz 实体类型
*/
public void deleteById(String id, Class<?> clazz);

/**
* 通用的高亮分页搜索接口
*
* @param esclz ES 模型字段字节码对象 => ES 查询的对象
* @param dtoclz domain 字节码对象 => 最终希望返回的对象
* @param qo 查询对象 => 封装分页参数
* @param parser 解析器对象 => 利用 ES 对象中的 id 属性,从数据库中查到对应完整的模型对象,返回最终结果
* @param fields 需要进行高亮查询的字段
* @param <T>
* @return 高亮分页数据对象
*/
<T> Page<T> searchWithHighlight(Class<?> esclz, Class<T> dtoclz, QueryObject qo, ElasticsearchTypeParser<T> parser, String... fields);
}

在包com.swx.search.service.impl下创建 ElasticsearchServiceImpl

import com.alibaba.fastjson2.JSON;
import com.swx.common.core.qo.QueryObject;
import com.swx.search.parser.ElasticsearchTypeParser;
import com.swx.search.service.ElasticsearchService;
import com.swx.search.utils.BeanUtils;
import org.elasticsearch.index.query.MultiMatchQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.*;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class ElasticsearchServiceImpl implements ElasticsearchService {

public static final Logger log = LoggerFactory.getLogger(ElasticsearchServiceImpl.class);

@Autowired
private ElasticsearchRestTemplate template;

@Override
public void save(Object entity) {
template.save(entity);
}

@Override
public void save(Iterable<?> iterable) {
template.save(iterable);
}

@Override
public void deleteById(String id, Class<?> clazz) {
template.delete(id, clazz);
}

@Override
public <T> Page<T> searchWithHighlight(Class<?> esclz, Class<T> dtoclz, QueryObject qo, ElasticsearchTypeParser<T> parser, String... fields) {
//高亮显示
/*"query":{
"multi_match": {
"query": "广州",
"fields": ["title","subTitle","summary"]
}
},*/
MultiMatchQueryBuilder queryBuilder = QueryBuilders.multiMatchQuery(qo.getKeyword(), fields);
HighlightBuilder highlightBuilder = new HighlightBuilder(); // 生成高亮查询器
for (String field : fields) {
highlightBuilder.field(field);// 高亮查询字段
}
highlightBuilder.requireFieldMatch(false); // 如果要多个字段高亮,这项要为false
highlightBuilder.preTags("<span style='color:red'>"); // 高亮设置
highlightBuilder.postTags("</span>");
highlightBuilder.fragmentSize(800000); // 最大高亮分片数
highlightBuilder.numOfFragments(0); // 从第一个分片获取高亮片段
/**
"from": 0,
"size":3,
*/
Pageable pageable = PageRequest.of(qo.getCurrent() - 1, qo.getSize(),
Sort.Direction.ASC, "_id");// 设置分页参数

NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(queryBuilder) // match查询
.withPageable(pageable)
.withHighlightBuilder(highlightBuilder) // 设置高亮
.build();

// 高亮查询, 得到命中的数据
SearchHits<?> searchHits = template.search(searchQuery, esclz);

// 最终返回的 list 对象
List<T> list = new ArrayList<>();
for (SearchHit<?> searchHit : searchHits) { // 获取搜索到的数据
// 具体解析操作: 交给外部调用者去实现
T target = parser.parse(dtoclz, searchHit.getId());

// 处理高亮
Map<String, String> map = highlightFieldsCopy(searchHit.getHighlightFields(), fields);

//1:spring 框架中 BeanUtils 类,如果是map集合是无法进行属性复制
// copyProperties(源, 目标)
//2: apache BeanUtils 类 可以进map集合属性复制
// copyProperties(目标, 源)
try {
T highlight = JSON.parseObject(JSON.toJSONString(map), dtoclz);
BeanUtils.copyProperties(highlight, target);
} catch (Exception e) {
log.warn("[高亮搜索] 拷贝属性失败", e);
}
list.add(target);
}

return new PageImpl<>(list, pageable, searchHits.getTotalHits());
}

//fields: title subTitle summary
private Map<String, String> highlightFieldsCopy(Map<String, List<String>> map, String... fields) {
Map<String, String> mm = new HashMap<>();
//title: "有娃必看,<span style='color:red;'>广州</span>长隆野生动物园全攻略"
//subTitle: "<span style='color:red;'>广州</span>长隆野生动物园"
//summary: "如果要说动物园,楼主强烈推荐带娃去<span style='color:red;'>广州</span>长隆野生动物园
//title subTitle summary
for (String field : fields) {
List<String> hfs = map.get(field);
if (hfs != null && !hfs.isEmpty()) {
//获取高亮显示字段值, 因为是一个数组, 所有使用string拼接

StringBuilder sb = new StringBuilder();
for (String hf : hfs) {
sb.append(hf);
}
mm.put(field, sb.toString()); //使用map对象将所有能替换字段先缓存, 后续统一替换
}
}
return mm;
}
}