ES应用之搜索附近的人

需求:

通过指定点搜索附近的人 , 要求可以过滤年龄, 结果按照距离进行排序, 并且展示她/他距离你多远

设计:

ES提供了很多地理位置的搜索方式 :
一般常用的是前两者。

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
}
posted @ 2021-12-05 10:58  晨煦风清  阅读(676)  评论(0编辑  收藏  举报