前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >基于SpringBoot、Elasticsearch实现酒店查询功能!

基于SpringBoot、Elasticsearch实现酒店查询功能!

原创
作者头像
Java程序猿
修改2021-02-19 15:52:43
9130
修改2021-02-19 15:52:43
举报
文章被收录于专栏:Java核心技术

前言

在一些应用中,Elasticsearch应用于全文搜索的很少,多是用于ToC端的查询,更像是一个缓存数据库。

与ToB的查询项是确定的不同——例如查名字,性别,地址,直接去DB对应的字段查就可以了,ToC大多是一个搜索框,对应多个查询项,如图:

这时候用DB就显得很不优雅了,用Elasticsearch就比较好了。

本文内容主要是通过基于SpringBoot、Elasticsearch实现对酒店查询的功能,列出一些常用Elasticsearch的API。

功能有:

1、在Elasticsearch上创建hotel索引,并配置映射。

2、从MySQL批量导入酒店信息至Elasticsearch。

3、添加单条酒店信息至MySQL、Elasticsearch。

4、根据关键词从Elasticsearch查询酒店列表。

5、根据坐标从Elasticsearch查询酒店列表。

6、根据价格范围从Elasticsearch查询酒店列表。

注:因为主要目的是展示常用的API,所以在设计上没有优化,例如数据库表设计上有一些不合理的,price不应出现在酒店表里;代码上没有优化,例如分页、自定义排序等就直接写死在代码里了;Elasticsearch也没有配置完全,分词器没有使用常用的IK。

代码语言:txt
复制
关注公众号:麒麟改bug,一起探讨Java技术交流,获取核心学习笔记

代码&讲述

pom.xml

代码语言:txt
复制
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.4.0</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<groupId>org.leo</groupId>
	<artifactId>hotel-server</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>hotel-server</name>
	<description>酒店-MySQL-ES</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<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.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.4</version>
		</dependency>

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
		<dependency>
			<groupId>com.google.guava</groupId>
			<artifactId>guava</artifactId>
			<version>29.0-jre</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
		<dependency>
			<groupId>com.google.code.gson</groupId>
			<artifactId>gson</artifactId>
		</dependency>
	</dependencies>

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

</project>

application.properties

代码语言:txt
复制
server.servlet.context-path=/hotel
server.port=8080
#AOP
spring.aop.proxy-target-class=true

#JDBC
spring.datasource.name=mall
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/mall?useUnicode=true&useSSL=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#指定XML文件目录
mybatis.mapper-locations=classpath:mapper/*.xml
#开启下划线与驼峰的转换
mybatis.configuration.map-underscore-to-camel-case=true

建表语句

代码语言:txt
复制
CREATE TABLE `t_hotel` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `hotel_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '酒店名称',
  `province` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '省',
  `city` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '市',
  `area` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '区',
  `location` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '经纬度',
  `landmark` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '地标',
  `label` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '标签',
  `price` int(11) NOT NULL DEFAULT '0' COMMENT '价格',
  `available_flag` tinyint(4) NOT NULL DEFAULT '2' COMMENT '营业标识。1营业2歇业',
  `hotel_desc` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '描述',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开店时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='酒店表';

仅做展示用,缺失一些字段,也有一些不合理的字段。

实体类&VO

省略get/set

代码语言:txt
复制
public class HotelEntity implements Serializable {
    private static final long serialVersionUID = 1980059323208910883L;

    /**
     * id
     */
    private Integer id;

    /**
     * 酒店名称
     */
    private String hotelName;

    /**
     * 省
     */
    private String province;
    /**
     * 市
     */
    private String city;
    /**
     * 区
     */
    private String area;

    /**
     * 经纬度
     */
    private String location;

    /**
     * 地标
     */
    private String landmark;

    /**
     * 标签
     */
    private String label;

    /**
     * 价格
     */
    private Integer price;

    /**
     * 营业标识。1营业2歇业
     */
    private Integer availableFlag;

    /**
     * 描述
     */
    private String hotelDesc;

    /**
     * 开店时间
     */
    private Date createTime;
}
代码语言:txt
复制
public class GeoLocation implements Serializable {
    private static final long serialVersionUID = 8940851639489109853L;
    private double lat;
    private double lon;
}
代码语言:txt
复制
public class HotelESVO implements Serializable {
    private static final long serialVersionUID = -2334463717030263164L;

    /**
     * id
     */
    private Integer id;

    /**
     * 酒店名称
     */
    private String hotelName;

    /**
     * 省
     */
    private String province;
    /**
     * 市
     */
    private String city;
    /**
     * 区
     */
    private String area;

    /**
     * 经纬度
     */
    private GeoLocation location;

    /**
     * 地标
     */
    private String landmark;

    /**
     * 标签
     */
    private String label;

    /**
     * 价格
     */
    private Integer price;

    /**
     * 营业标识。1营业2歇业
     */
    private Integer availableFlag;

    /**
     * 描述
     */
    private String hotelDesc;

    /**
     * 开店时间
     */
    private String createTime;

    /**
     * 查询关键词
     */
    private String searchKeywords;
}

HotelESVO与HotelEntity有些不同:

1、经纬度变成了GeoLocation类。

2、多了searchKeywords,供搜索用。

说一下为什么加了这个字段,用户搜索时,一般就是搜酒店名称、省区市、地标(王府井、春熙路、锦里等)、标签(网红打卡、场站接送等),全文去搜反而不如将这些关键词统一放在一起,只搜这一个字段。

3、时间从Date变成String,因为Elasticsearch不认Date

Hotel索引映射

代码语言:txt
复制
{
	"properties": {
		"id": {
			"type": "integer"
		},
		"hotelName": {
			"type": "keyword"
		},
		"province": {
			"type": "keyword"
		},
		"city": {
			"type": "keyword"
		},
		"area": {
			"type": "keyword"
		},
		"location": {
			"type": "geo_shape"
		},
		"landmark": {
			"type": "keyword"
		},
		"label": {
			"type": "keyword"
		},
		"price": {
			"type": "integer"
		},
		"availableFlag": {
			"type": "integer"
		},
		"hotelDesc": {
			"type": "text",
			"index": "false"
		},
		"searchKeywords": {
			"type": "text",
			"analyzer": "whitespace"
		},
		"createTime": {
			"type": "date",
			"format": "yyyy-MM-dd HH:mm:ss"
		}
	}
}

有几个地方要注意:

1、为了演示方便,searchKeywords使用的分词器是空格分词器,没用IK。

2、一些字段没必要进行索引,例如hotelDesc的索引我就给禁了。

3、经纬度的映射是geo_shape。

4、日期时间要配置格式化。

RestClientConfig

代码语言:txt
复制
@Configuration
public class RestClientConfig extends AbstractElasticsearchConfiguration {

    @Override
    @Bean
    public RestHighLevelClient elasticsearchClient() {
        final ClientConfiguration clientConfiguration = ClientConfiguration.builder().connectedTo("127.0.0.1:9200")
                .build();

        return RestClients.create(clientConfiguration).rest();
    }

}

SpringBoot中,原来有一个ElasticsearchTemplate可以与Elasticsearch交互,但是现在被废弃了,现在应该使用Java High Level REST Client。

跟数据库交互的Mapper、Service就不列出来了。

Controller的结构

代码语言:txt
复制
@RestController
public class HotelController {
    private static final Logger logger = LoggerFactory.getLogger(HotelController.class);
    @Autowired
    private HotelService hotelService;
    @Autowired
    RestHighLevelClient highLevelClient;

    private HotelESVO buildVoFromEntity(HotelEntity hotelEntity) {
        // 可用复制工具
        HotelESVO vo = new HotelESVO();
        vo.setArea(hotelEntity.getArea());
        vo.setAvailableFlag(hotelEntity.getAvailableFlag());
        vo.setCity(hotelEntity.getCity());
        vo.setHotelDesc(hotelEntity.getHotelDesc());
        vo.setHotelName(hotelEntity.getHotelName());
        vo.setId(hotelEntity.getId());
        vo.setLabel(hotelEntity.getLabel());
        vo.setLandmark(hotelEntity.getLandmark());
        vo.setPrice(hotelEntity.getPrice());
        vo.setProvince(hotelEntity.getProvince());
        // 处理时间
        vo.setCreateTime(LocalDateTime.fromDateFields(hotelEntity.getCreateTime()).toString("yyyy-MM-dd HH:mm:ss"));
        // 处理坐标
        GeoLocation location = new GeoLocation();
        location.setLat(Double.valueOf(hotelEntity.getLocation().split(",")[1]));
        location.setLon(Double.valueOf(hotelEntity.getLocation().split(",")[0]));
        vo.setLocation(location);
        // 处理查询关键词 用set去重一下
        String[] labelArr = hotelEntity.getLabel().split(",");
        String[] landMarkArr = hotelEntity.getLandmark().split(",");

        Set<String> skw = Sets.newHashSet();
        skw.addAll(Arrays.asList(labelArr));
        skw.addAll(Arrays.asList(landMarkArr));
        skw.add(hotelEntity.getProvince());
        skw.add(hotelEntity.getArea());
        skw.add(hotelEntity.getCity());
        skw.add(hotelEntity.getHotelName());

        String searchKeywords = Joiner.on(" ").skipNulls().join(skw);
        vo.setSearchKeywords(searchKeywords);
        return vo;
    }
}

私有方法是Entity转VO。

后续的方法都写在Controller里面,实际工作中,要看公司的规范要求,选择合适的地方。

添加索引

代码语言:txt
复制
@GetMapping("/addHotelIndex")
    @ResponseBody
    public CreateIndexResponse addHotelIndex() throws IOException {
        CreateIndexRequest req = new CreateIndexRequest("hotel");
        req.settings(Settings.builder().put("index.number_of_shards", 1).put("index.number_of_replicas", 1));
        req.mapping(
                "{\n" + "    \"properties\": {\n" + "        \"id\": {\n" + "            \"type\": \"integer\"\n"
                        + "        },\n" + "        \"hotelName\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"province\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"city\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"area\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"location\": {\n" + "            \"type\": \"geo_point\"\n"
                        + "        },\n" + "        \"landmark\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"label\": {\n" + "            \"type\": \"keyword\"\n"
                        + "        },\n" + "        \"price\": {\n" + "            \"type\": \"integer\"\n"
                        + "        },\n" + "        \"availableFlag\": {\n" + "            \"type\": \"integer\"\n"
                        + "        },\n" + "        \"hotelDesc\": {\n" + "            \"type\": \"text\",\n"
                        + "            \"index\": \"false\"\n" + "        },\n" + "        \"searchKeywords\": {\n"
                        + "            \"type\": \"text\",\n" + "            \"analyzer\": \"whitespace\"\n"
                        + "        },\n" + "        \"createTime\": {\n" + "            \"type\": \"date\",\n"
                        + "            \"format\": \"yyyy-MM-dd HH:mm:ss\"\n" + "        }\n" + "    }\n" + "}",
                XContentType.JSON);
        CreateIndexResponse createIndexResponse = highLevelClient.indices().create(req, RequestOptions.DEFAULT);
        return createIndexResponse;
    }

一般来说,在Elasticsearch上创建索引就像在MySQL上创建表,应该是执行脚本或者使用Kibana,本文就是想展示一下相关API,才放在代码里了。

将数据库里的数据导入ES

代码语言:txt
复制
@GetMapping("/importHotel2ES")
    @ResponseBody
    public BulkResponse importHotel2ES() throws IOException {
        // 为防止数据过大,可以批量获取
        List<HotelEntity> hotels = hotelService.findAll();
        BulkRequest bulkReq = new BulkRequest("hotel");
        Gson gson = new Gson();
        for (HotelEntity hotelEntity : hotels) {
            HotelESVO vo = this.buildVoFromEntity(hotelEntity);
            IndexRequest req = new IndexRequest();
            req.id(hotelEntity.getId().toString());
            req.source(gson.toJson(vo), XContentType.JSON);
            bulkReq.add(req);
        }
        BulkResponse bulkRes = highLevelClient.bulk(bulkReq, RequestOptions.DEFAULT);
        return bulkRes;
    }

有可能在实际工作中,Elasticsearch是后上的,所以有个批量导入的初始化方法还是很有必要的,为防止数据量过大,应该分批导入。

这里主要是展示一下Elasticsearch的批量操作API。

添加酒店信息:

代码语言:txt
复制
    @PostMapping("/add")
    @ResponseBody
    public HotelEntity add(@RequestBody HotelEntity entity) throws IOException {
        entity.setCreateTime(new Date());
        hotelService.add(entity);
        logger.info("保存成功:" + entity.toString());
        // 同步到ES
        Gson gson = new Gson();
        HotelESVO vo = this.buildVoFromEntity(entity);
        IndexRequest req = new IndexRequest("hotel");
        req.id(entity.getId().toString());
        req.source(gson.toJson(vo), XContentType.JSON);
        IndexResponse indexResponse = highLevelClient.index(req, RequestOptions.DEFAULT);
        logger.info("同步ES:" + indexResponse.toString());
        return entity;
    }

在实际工作中,根据业务要求,同步数据到Elasticsearch的工作可以采用上面的方法,实时性得到保障。也可以使用Elasticsearch提供的异步方法indexAsync。或者写一个定时器,每隔一段时间从数据库批量导入。

关键词查询

代码语言:txt
复制
    @GetMapping("/search")
    @ResponseBody
    public List<HotelESVO> search(@RequestParam String keywords) throws IOException {
        SearchRequest searchRequest = new SearchRequest("hotel");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        // 查询 默认是OR
        sourceBuilder.query(QueryBuilders.matchQuery("searchKeywords", keywords).operator(Operator.AND));
        // 分页
        sourceBuilder.from(0);
        sourceBuilder.size(10);
        // 排序
        sourceBuilder.sort(new FieldSortBuilder("id").order(SortOrder.DESC));
        // 定制要返回的信息,隐藏的信息
        String[] includeFields = new String[] { "id", "hotelName", "province", "city", "area", "location", "landmark",
                "label", "availableFlag", "hotelDesc", "price", "createTime" };
        String[] excludeFields = new String[] { "searchKeywords" };
        sourceBuilder.fetchSource(includeFields, excludeFields);
        searchRequest.source(sourceBuilder);
        SearchResponse res = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
        SearchHit[] searchHits = res.getHits().getHits();

        Gson gson = new Gson();
        List<HotelESVO> vos = Lists.newArrayList();
        for (SearchHit hit : searchHits) {
            String sourceAsString = hit.getSourceAsString();
            vos.add(gson.fromJson(sourceAsString, HotelESVO.class));
        }
        return vos;
    }

注意QueryBuilders最后面的operator,默认是or,如果用户输入"北京 四川",只要searchKeywords包含北京或四川,都会搜索出来,如果是and,就必须同时包含北京和四川才行。

如果是or,这次搜索会把其他北京的酒店也搜索出来。实际工作中用or还是and,按照业务来。

根据经纬度搜索

范围的单位是公里,排序是由近及远。

代码语言:txt
复制
    @GetMapping("/searchGeo")
    @ResponseBody
    public List<HotelESVO> searchGeo(@RequestParam Double lat, @RequestParam Double lon, @RequestParam Integer distance)
            throws IOException {
        SearchRequest searchRequest = new SearchRequest("hotel");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        // 查询
        sourceBuilder.query(
                QueryBuilders.geoDistanceQuery("location").point(lat, lon).distance(distance, DistanceUnit.KILOMETERS));
        // 分页
        sourceBuilder.from(0);
        sourceBuilder.size(10);
        // 排序
        sourceBuilder.sort(new GeoDistanceSortBuilder("location", lat, lon).order(SortOrder.DESC));
        // 定制要返回的信息,隐藏的信息
        String[] includeFields = new String[] { "id", "hotelName", "province", "city", "area", "location", "landmark",
                "label", "availableFlag", "hotelDesc", "price", "createTime" };
        String[] excludeFields = new String[] { "searchKeywords" };
        sourceBuilder.fetchSource(includeFields, excludeFields);
        searchRequest.source(sourceBuilder);
        SearchResponse res = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
        SearchHit[] searchHits = res.getHits().getHits();

        Gson gson = new Gson();
        List<HotelESVO> vos = Lists.newArrayList();
        for (SearchHit hit : searchHits) {
            String sourceAsString = hit.getSourceAsString();
            vos.add(gson.fromJson(sourceAsString, HotelESVO.class));
        }
        return vos;
    }

价格范围搜索

代码语言:txt
复制
    @GetMapping("/searchPrice")
    @ResponseBody
    public List<HotelESVO> searchPrice(@RequestParam(required = false) Integer min,
            @RequestParam(required = false) Integer max) throws IOException {
        if (Objects.isNull(min)) {
            min = 0;
        }
        if (Objects.isNull(max)) {
            max = Integer.MAX_VALUE;
        }
        SearchRequest searchRequest = new SearchRequest("hotel");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        // 查询
        sourceBuilder.query(QueryBuilders.rangeQuery("price").gte(min).lte(max));
        // 分页
        sourceBuilder.from(0);
        sourceBuilder.size(10);
        // 排序
        sourceBuilder.sort(new FieldSortBuilder("price").order(SortOrder.ASC));
        // 定制要返回的信息,隐藏的信息
        String[] includeFields = new String[] { "id", "hotelName", "province", "city", "area", "location", "landmark",
                "label", "availableFlag", "hotelDesc", "price", "createTime" };
        String[] excludeFields = new String[] { "searchKeywords" };
        sourceBuilder.fetchSource(includeFields, excludeFields);
        searchRequest.source(sourceBuilder);
        SearchResponse res = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
        SearchHit[] searchHits = res.getHits().getHits();

        Gson gson = new Gson();
        List<HotelESVO> vos = Lists.newArrayList();
        for (SearchHit hit : searchHits) {
            String sourceAsString = hit.getSourceAsString();
            vos.add(gson.fromJson(sourceAsString, HotelESVO.class));
        }
        return vos;
    }
    关注公众号:麒麟改bug,一起探讨Java技术交流,获取核心学习笔记

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 代码&讲述
相关产品与服务
云数据库 MySQL
腾讯云数据库 MySQL(TencentDB for MySQL)为用户提供安全可靠,性能卓越、易于维护的企业级云数据库服务。其具备6大企业级特性,包括企业级定制内核、企业级高可用、企业级高可靠、企业级安全、企业级扩展以及企业级智能运维。通过使用腾讯云数据库 MySQL,可实现分钟级别的数据库部署、弹性扩展以及全自动化的运维管理,不仅经济实惠,而且稳定可靠,易于运维。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档