1.Redis中的GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
2.附近商铺功能
2.1 应用场景
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
2.2 思路分析
使用Redis的GEO数据类型为我们基于位置查询商铺的功能。我们需要做一下的工作
- 首先就是要将所有的商品的信息和地理位置坐标都保存到redis中。但是考虑到redis基于内存,空间受限,因此在redis中仅仅保存地理位置坐标和商铺的id。
- 其次是用户会根据类别来查询商铺的距离,因此我们不能将所有的商铺信息都保存到一个redis的key中,这样我们在查找的时候无法区别商铺的类型,因此应该分别保存。
- 因此我们在redis中应该这样保存商铺的信息
- key使用geo:shop:TypeId
- 键就是shopId和地理位置坐标
2.3 代码实现
将商铺信息写入redis,这里我们直接在测试类中将信息写入。
@Test
public void loadDataToRedis() {
// 获取所有商家信息
List<Shop> shopList = shopService.list();
// 按照商家类型分组
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 遍历所有商家 将商家添加到redis中
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
List<Shop> list = entry.getValue();
// 这里面的那个泛型指的是geo里面保存的那个member的值的类型 这里我们保存的是shopId
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(list.size());
// 遍历当前typeId的所有商铺信息
for (Shop shop : list) {
RedisGeoCommands.GeoLocation<String> geoLocation = new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
);
locations.add(geoLocation);
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
题外话:这里我们首先查询了所有的商铺信息,然后根据TypeId分别生成不同的list。使用了stream流中相关语法,可以快速的根据某一个值进行分组,返回值为map。key是分组的那个字段,值为每一个的list
附近商铺功能相关代码
/**
* @param typeId 商铺类型ID
* @param current 页号
* @param x 经度
* @param y 纬度
* @return
*/
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// TODO 如果没有位置坐标,则按照之前的查询方式查询
if (x == null || y == null) {
// 根据类型分页查询
Page<Shop> page = super.query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 计算分页信息
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
String key = SHOP_GEO_KEY + typeId;
// TODO 如果有位置坐标 则需要根据位置坐标到Redis中查询店铺的ID
GeoResults<RedisGeoCommands.GeoLocation<String>> result = stringRedisTemplate.opsForGeo().radius(
key,
new Circle(new Point(x, y), new Distance(5, Metrics.KILOMETERS)), // 方圆5KM
RedisGeoCommands.GeoRadiusCommandArgs.
newGeoRadiusArgs().
includeDistance(). // 查询结果包含距离
sortAscending(). // 按照距离从近到远
limit(end) // 分页 显示从第一条到第end条
);
// TODO 根据从redis中查询到的结果封装
if (result == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = result.getContent();
if (content.size() <= from) {
return Result.ok(Collections.emptyList());
}
List<Long> shopIdList = new ArrayList<>(content.size());
Map<String, Distance> distanceMap = new HashMap<>(content.size());
content.stream().skip(from).forEach(geoResult -> {
String shopIdStr = geoResult.getContent().getName();
shopIdList.add(Long.valueOf(shopIdStr));
Distance distance = geoResult.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 根据shopId查询商铺信息
String idsStr = StrUtil.join(",", shopIdList);
List<Shop> shopList = super.query().in("id", shopIdList).last("order by field(id, " + idsStr + ")").list();
for (Shop shop : shopList) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shopList);
}
需要注意的是,使用geo查询附近的商铺指定查询多少条记录都是从第一条记录开始。比如指定查询5条,那么结果就是从第1条到第5条。换句话说只能指定终止位置,起始位置始终是第1条记录。
GeoResults<RedisGeoCommands.GeoLocation<String>> result = stringRedisTemplate.opsForGeo().radius(
key,
new Circle(new Point(x, y), new Distance(5, Metrics.KILOMETERS)), // 方圆5KM
RedisGeoCommands.GeoRadiusCommandArgs.
newGeoRadiusArgs().
includeDistance(). // 查询结果包含距离
sortAscending(). // 按照距离从近到远
limit(end) // 分页 显示从第一条到第end条 只能指定终止位置,起始位置始终是第1条记录。
);
因此我们如果查询第2页的内容也只能是跳过一部分元素
content.stream().skip(from)
另外,判断是否还有元素没有读取的方法也变成了下面这样。
// 计算分页信息
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; // 起始位置
int end = current * SystemConstants.DEFAULT_PAGE_SIZE; // 终止位置
// 如果起始位置的长度都大于等于content的长度了,说明没有新店铺了。
if (content.size() <= from) {
return Result.ok(Collections.emptyList());
}