ES应用之搜索附近的人
需求:
通过指定点搜索附近的人 , 要求可以过滤年龄, 结果按照距离进行排序, 并且展示她/他距离你多远
设计:
ES提供了很多地理位置的搜索方式 :
- geo_bounding_box: 找出落在指定矩形框中的点。
- geo_distance: 找出与指定位置在给定距离内的点。
- geo_distance_range: 找出与指定点距离在给定最小距离和最大距离之间的点。
一般常用的是前两者。
1. 地理坐标盒模型过滤器
这是目前为止最有效的地理坐标过滤器了,因为它计算起来非常简单。 指定一个矩形,然后过滤器只需判断坐标的经度是否在左右边界之间,纬度是否在上下边界之间:一般只要设定左上的坐标 和右下的坐标即可。
GET /attractions/restaurant/_search { "query": { "filtered": { "filter": { "geo_bounding_box": { "type": "indexed", "location": { "top_left": { "lat": 40.8, "lon": -74.0 }, "bottom_right": { "lat": 40.7, "lon": -73.0 } } } } } } }
2. 地理距离过滤器
地理距离过滤器( geo_distance )以给定位置为圆心画一个圆,来找出那些地理坐标落在其中的文档。
GET /attractions/restaurant/_search { "query": { "filtered": { "filter": { "geo_distance": { "distance": "1km", "location": { "lat": 40.715, "lon": -73.988 } } } } } }
距离单位es官方给我们提供了很多种: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/common-options.html#distance-units。常用的m, km就够了。
3. 地理位置排序
检索结果可以按与指定点的距离排序,当可以按距离排序时, 按距离打分 通常是一个更好的解决方案。但是要计算当前距离,所以还是使用这个排序。搜索示例:
GET /attractions/restaurant/_search { "query": { "filtered": { "filter": { "geo_bounding_box": { "type": "indexed", "location": { "top_left": { "lat": 40.8, "lon": -74.0 }, "bottom_right": { "lat": 40.4, "lon": -73.0 } } } } } }, "sort": [ { "_geo_distance": { "location": { "lat": 40.715, "lon": -73.998 }, "order": "asc", "unit": "km", "distance_type": "plane" } } ] }
解读以下: (注意看sort对象)
- 计算每个文档中 location 字段与指定的 lat/lon 点间的距离。
- 将距离以 km 为单位写入到每个返回结果的 sort 键中。
- 使用快速但精度略差的 plane 计算方式。
环境准备
使用ElasticSearch 7.8.0版本,引入依赖。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.0-SNAPSHOT</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> <version>2.4.0-SNAPSHOT</version> </dependency>
1. 设计数据库字段和ES的字段mapping
数据库字段设计: 添加两个字段经纬度
CREATE TABLE `es_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL, `age` int(5) DEFAULT NULL, `tags` varchar(255) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '多标签用 ''|'' 分割', `user_desc` varchar(255) COLLATE utf8mb4_bin DEFAULT '' COMMENT '用户简介', `is_deleted` varchar(1) COLLATE utf8mb4_bin NOT NULL DEFAULT 'N', `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP, `gmt_modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `lat` decimal(10,6) DEFAULT '0.000000' COMMENT '维度', `lon` decimal(10,6) DEFAULT '0.000000' COMMENT '经度', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=657 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
实体类:
/** * (EsUser)实体类 */ @Data public class EsUserEntity implements Serializable { private static final long serialVersionUID = 578800011612714754L; /** * 主键 */ private Long id; private String name; private Integer age; /** * 多标签用 '|' 分割 */ private String tags; /** * 用户简介 */ private String userDesc; private String isDeleted = "0"; private Date gmtCreate; private Date gmtModified; // 经度 private Double lat; // 维度 private Double lon; }
Java中对象映射:
@Data @Document(indexName = "es_user") public class ESUser { @Id private Long id; @Field(type = FieldType.Text) private String name; @Field(type = FieldType.Integer) private Integer age; @Field(type = FieldType.Keyword) private List<String> tags; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String desc; @GeoPointField private GeoPoint location; }
2. 准备海量mock数据
这里以经度:120.24 维度:30.3 作为圆心, mock一些附近的点作为mock用户的坐标数据
@ApiOperation("录入测试") @PostMapping("/content/test-insert") public Long importEsUser(Long num) { for (int i = 0; i < num; i++) { ThreadPoolUtil.execute(() -> { EsUserEntity esUser = generateRandomMockerUser(); esUserService.importEsUser(esUser); }); } return num; } // mock随机用户数据 private EsUserEntity generateRandomMockerUser() { // 120.247589,30.306362 EsUserEntity esUserEntity = new EsUserEntity(); int age = new Random().nextInt(20) + 5; esUserEntity.setAge(age); boolean flag = age % 2 > 0; esUserEntity.setName(flag ? RandomCodeUtil.getRandomChinese("0") : RandomCodeUtil.getRandomChinese("1")); esUserEntity.setTags(flag ? "善良|Java|帅气" : "可爱|稳重|React"); esUserEntity.setUserDesc(flag ? "大闹天宫,南天门守卫, 擅长编程, 烹饪" : "天空守卫,擅长编程,睡觉"); String latRandNumber = RandomCodeUtil.getRandNumberCode(4); String lonRandNumber = RandomCodeUtil.getRandNumberCode(4); esUserEntity.setLon(Double.valueOf("120.24" + latRandNumber)); esUserEntity.setLat(Double.valueOf("30.30" + lonRandNumber)); return esUserEntity; }
设计返回给前台的对象和搜索条件类
/** * 功能描述:ES的用户搜索结果 */ @Data public class PeopleNearByVo { private ESUserVo esUserVo; private Double distance; }
/** * 功能描述:ES的用户搜索结果 */ @Data public class ESUserVo { private Long id; private String name; private Integer age; private List<String> tags; // 高亮部分 private List<String> highLightTags; private String desc; // 高亮部分 private List<String> highLightDesc; // 坐标 private GeoPoint location; }
搜索类
/** * 功能描述: 搜索附近的人 */ @Data public class ESUserLocationSearch { // 纬度 [3.86, 53.55] private Double lat; // 经度 [73.66, 135.05] private Double lon; // 搜索范围(单位米) private Integer distance; // 年龄大于等于 private Integer ageGte; // 年龄小于 private Integer ageLt; }
核心搜索方法:
/** * 搜索附近的人 * @param locationSearch * @return */ public Page<PeopleNearByVo> queryNearBy(ESUserLocationSearch locationSearch) { Integer distance = locationSearch.getDistance(); Double lat = locationSearch.getLat(); Double lon = locationSearch.getLon(); Integer ageGte = locationSearch.getAgeGte(); Integer ageLt = locationSearch.getAgeLt(); // 先构建查询条件 BoolQueryBuilder defaultQueryBuilder = QueryBuilders.boolQuery(); // 距离搜索条件 if (distance != null && lat != null && lon != null) { defaultQueryBuilder.filter(QueryBuilders.geoDistanceQuery("location") .distance(distance, DistanceUnit.METERS) .point(lat, lon) ); } // 过滤年龄条件 if (ageGte != null && ageLt != null) { defaultQueryBuilder.filter(QueryBuilders.rangeQuery("age").gte(ageGte).lt(ageLt)); } // 分页条件 PageRequest pageRequest = PageRequest.of(0, 10); // 地理位置排序 GeoDistanceSortBuilder sortBuilder = SortBuilders.geoDistanceSort("location", lat, lon); //组装条件 NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(defaultQueryBuilder) .withPageable(pageRequest) .withSort(sortBuilder) .build(); SearchHits<ESUser> searchHits = elasticsearchRestTemplate.search(searchQuery, ESUser.class); List<PeopleNearByVo> peopleNearByVos = Lists.newArrayList(); for (SearchHit<ESUser> searchHit : searchHits) { ESUser content = searchHit.getContent(); ESUserVo esUserVo = new ESUserVo(); BeanUtils.copyProperties(content, esUserVo); PeopleNearByVo peopleNearByVo = new PeopleNearByVo(); peopleNearByVo.setEsUserVo(esUserVo); peopleNearByVo.setDistance((Double) searchHit.getSortValues().get(0)); peopleNearByVos.add(peopleNearByVo); } // 组装分页对象 Page<PeopleNearByVo> peopleNearByVoPage = new PageImpl<>(peopleNearByVos, pageRequest, searchHits.getTotalHits()); return peopleNearByVoPage; }
controller层
@RequestMapping(value = "/query-doc/nearBy", method = RequestMethod.POST) @ApiOperation("根据坐标点搜索附近的人") public Page<PeopleNearByVo> queryNearBy(@RequestBody ESUserLocationSearch locationSearch) { return esUserService.queryNearBy(locationSearch); }
4. swagger测试
下图的搜索条件为, 以北纬30.30,东经120.24为坐标点,搜索附近100米内 ,年龄大于等18岁, 小于25岁的人
找到了2个, 排序按照距离排序, 年龄区间正确, 第一个距离39米, 第二个距离85米, 结果正确。
{ "content": [ { "esUserVo": { "id": 601, "name": "季福林", "age": 22, "tags": [ "可爱", "稳重", "React" ], "highLightTags": null, "desc": "天空守卫,擅长编程,睡觉", "highLightDesc": null, "location": { "lat": 30.300214, "lon": 120.240329, "geohash": "wtms25urd9r8", "fragment": true } }, "distance": 39.53764107382481 }, { "esUserVo": { "id": 338, "name": "逄军", "age": 20, "tags": [ "可爱", "稳重", "React" ], "highLightTags": null, "desc": "天空守卫,擅长编程,睡觉", "highLightDesc": null, "location": { "lat": 30.300242, "lon": 120.240846, "geohash": "wtms25uxwy3p", "fragment": true } }, "distance": 85.56052789780142 } ], "pageable": { "sort": { "sorted": false, "unsorted": true, "empty": true }, "offset": 0, "pageNumber": 0, "pageSize": 10, "paged": true, "unpaged": false }, "last": true, "totalPages": 1, "totalElements": 2, "size": 10, "number": 0, "sort": { "sorted": false, "unsorted": true, "empty": true }, "numberOfElements": 2, "first": true, "empty": false }