利用Redis的Geo功能实现附近门店推荐

利用Redis的Geo功能实现附近门店推荐

1. 场景

小程序或者APP中,获取当前用户地理位置授权后,希望推荐给用户最近的门店

2. 介绍

Redis3.2版本之后,开始对GEO(地理位置)的支持,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能。GEO存储的数据类型为zset。
基础的指令如下:

1. geoadd:增加某个地理位置的坐标;
2. geopos:获取某个地理位置的坐标;
3. geodist:获取两个地理位置的距离;
4. georadius:根据给定地理位置坐标获取指定范围内的地理位置集合;
5. georadiusbymember:根据给定地理位置获取指定范围内的地理位置集合;
6. geohash:获取某个地理位置的geohash值。
7. zrem:对指定地理位置信息的删除

3. 代码实现

确定一个基础应用场景,获取用户附近最近一个门店。

3.1 Redis基础工具类

package wang.fredia.app.utils.redis;

import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metric;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
 * Redis工具类
 */
public class RedisUtil {

	@Autowired
	private StringRedisTemplate redisTemplate;

	public void setRedisTemplate(StringRedisTemplate redisTemplate) {
		this.redisTemplate = redisTemplate;
	}

	public StringRedisTemplate getRedisTemplate() {
		return this.redisTemplate;
	}

	/**------------------Geo相关操作--------------------------------*/
	
	/**
	 * 添加经纬度信息,时间复杂度为O(log(N)) redis 命令:geoadd cityGeo 116.405285 39.904989 "北京"
	 */
	public Long addGeoPoin(String key, Point point, String member) {
		Long addedNum = redisTemplate.opsForGeo().geoAdd(key, point, member);
		return addedNum;
	}

	/**
	 * 批量添加地址信息
	 */
	public Long addGeoPoinList(String key, Map<String, Point> memberCoordinateMap) {
		Long addedNum =redisTemplate.opsForGeo().geoAdd(key, memberCoordinateMap);
		return addedNum;
	}
	
	
	/**
	 * 查找指定key的经纬度信息,可以指定多个key,批量返回 redis命令:geopos cityGeo 北京
	 */
	public List<Point> geoGet(String key, String... members) {
		List<Point> points = redisTemplate.opsForGeo().geoPos(key, members);
		return points;
	}

	/**
	 * 返回两个地方的距离,可以指定单位,比如米m,千米km,英里mi,英尺ft redis命令:geodist cityGeo 北京 上海
	 * 
	 * @return
	 */
	public Distance geoDist(String key, String member1, String member2, Metric metric) {
		Distance distance = redisTemplate.opsForGeo().geoDist(key, member1, member2, metric);
		return distance;
	}

	/**
	 * 根据给定的经纬度,返回半径不超过指定距离的元素,时间复杂度为O(N+log(M)),N为指定半径范围内的元素个数,M为要返回的个数
	 * redis命令:georadius cityGeo 116.405285 39.904989 100 km WITHDIST WITHCOORD
	 * ASC COUNT 5
	 */
	public GeoResults<GeoLocation<String>> nearByXY(String key, Circle circle, RedisGeoCommands.GeoRadiusCommandArgs args) {
		GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo().geoRadius(key, circle,
				args);
		return results;
	}

	/**
	 * 根据指定的地点查询半径在指定范围内的位置,时间复杂度为O(log(N)+M),N为指定半径范围内的元素个数,M为要返回的个数
	 * redis命令:georadiusbymember cityGeo 北京 100 km WITHDIST WITHCOORD ASC COUNT
	 * 5
	 */
	public GeoResults<GeoLocation<String>> nearByPlace(String key, String member, Distance distance,
			RedisGeoCommands.GeoRadiusCommandArgs args) {
		GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo().geoRadiusByMember(key,
				member, distance, args);
		return results;
	}

	/**
	 * 返回的是geohash值,查找一个位置的时间复杂度为O(log(N)) redis命令:geohash cityGeo 北京
	 */
	public List geoHash(String key, String... members) {
		List<String> results = redisTemplate.opsForGeo().geoHash(key, members);
		return results;
	}
	
	/**
	 * 移除门店数据
	 * @author:Fredia

	 */
	public Long geoRemove(String key, String... members) {
		Long results = redisTemplate.opsForGeo().geoRemove(key, members);
		return results;
	}
}

3.2 GEO业务工具类

package wang.fredia.app.util;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation;
import org.springframework.stereotype.Component;

import com.xuanwei.app.resp.location.StoreLocationModel;
import com.xuanwei.app.utils.redis.RedisUtil;

@Component
public class GeoRedisUtils extends RedisUtil {
	private static Logger log = LoggerFactory.getLogger(GeoRedisUtils.class);

	private final static String STORE_LOCATION = "storeLocation";
	private final static double RADIUS = 5000.00;

	/**
	 * 增加门店地址
	 */
	public Long addStoreGeoLocation(Long storeId, Point point) {
		return super.addGeoPoin(STORE_LOCATION, point, storeId.toString());
	}

	/**
	 * 获取周边id
	 */
	public List<StoreLocationModel> getRecStoreList(Circle circle) {
		List<StoreLocationModel> storeLocationList = new ArrayList<StoreLocationModel>();
		RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
				.includeDistance().sortAscending();
		GeoResults<GeoLocation<String>> geoResults = super.nearByXY(STORE_LOCATION, circle, args);
		if (Objects.isNull(geoResults)) {
			return storeLocationList;
		}
		List<GeoResult<GeoLocation<String>>> lists = geoResults.getContent();
		if (Objects.nonNull(lists) && lists.size() > 0) {
			lists.stream().forEach(geoLocation -> {
				StoreLocationModel storeLocationModel = new StoreLocationModel();
				storeLocationModel.setStoreId(Long.parseLong(geoLocation.getContent().getName()));
				storeLocationModel.setMetric(geoLocation.getDistance().getUnit());
				storeLocationModel.setDistance(geoLocation.getDistance().getValue());
				storeLocationList.add(storeLocationModel);
			});
		}

		return storeLocationList;
	}

	/**
	 * 获取原点
	 */
	public static Point getPointByXY(double longitude, double latitude) {
		BigDecimal b = new BigDecimal(latitude);
		latitude = b.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();

		BigDecimal c = new BigDecimal(longitude);
		longitude = c.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();
		Point point = new Point(longitude, latitude);
		return point;
	}

	/**
	 * 获取原点
	 */
	public static Point getPointByXY(String longitude, String latitude) {
		BigDecimal b = new BigDecimal(latitude);
		double latitude2 = b.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();

		BigDecimal c = new BigDecimal(longitude);
		double longitude2 = c.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();
		Point point = new Point(longitude2, latitude2);
		return point;
	}

	/**
	 * 获取圆形区域
	 */
	public static Circle getCircleByXY(double longitude, double latitude) {
		BigDecimal b = new BigDecimal(latitude);
		latitude = b.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();

		BigDecimal c = new BigDecimal(longitude);
		longitude = c.setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();
		Point point = new Point(longitude, latitude);
		Distance distance = new Distance(RADIUS, Metrics.KILOMETERS);
		Circle circle = new Circle(point, distance);
		return circle;
	}

	/**
	 * 删除全部地理位置表
	 */
	public void batchRemoveStoreGeo() {
		super.del(STORE_LOCATION);
	}

	/**
	 * 批量插入门店
	 */
	public void batchAddStoreGeo(Map<String, Point> memberCoordinateMap) {
		super.addGeoPoinList(STORE_LOCATION, memberCoordinateMap);
	}

	/**
	 * 批量删除地理位置表
	 */
	public void batchDelStoreGeo(String... storeIds) {
		super.geoRemove(STORE_LOCATION, storeIds);
	}
}

3.3 门店实体类

public class StoreLocationModel {
	
	private Long storeId;

	private String metric;

	private double distance;

	/**
	 * @return the storeId
	 */
	public Long getStoreId() {
		return storeId;
	}

	/**
	 * @param storeId the storeId to set
	 */
	public void setStoreId(Long storeId) {
		this.storeId = storeId;
	}

	/**
	 * @return the metric
	 */
	public String getMetric() {
		return metric;
	}

	/**
	 * @param metric the metric to set
	 */
	public void setMetric(String metric) {
		this.metric = metric;
	}

	/**
	 * @return the distance
	 */
	public double getDistance() {
		return distance;
	}

	/**
	 * @param distance the distance to set
	 */
	public void setDistance(double distance) {
		this.distance = distance;
	}
	
}

4. 验证

总体思路:

  1. 通过用户授权的LongitudeLatitude生成Point(圆心)
  2. Point作为圆心,以**new Distance(RADIUS, Metrics.KILOMETERS)**生产的Distance作为半径画圆
  3. 生成最终的Circle,即我们期望的一个区域
  4. Circle范围内,外加GEO的筛选条件,返回给用户满足条件的zet列表

4.1 插入一个门店GEO信息

     Long storeId = storeLocationModel.getStoreId();	
		Circle circle = GeoRedisUtils.getCircleByXY(storeLocationModel.getLongitude(),
				storeLocationModel.getLatitude());
	geoRedisUtils.addStoreGeoLocation(storeId,circle);

按照例子执行了一下,注意zset的score以及命令行
新增一个门店GEO

4.2 获取用户附近门店

具体使用方法如下,获取后的门店可以按照业务需求进行再次排序

		Circle circle = GeoRedisUtils.getCircleByXY(user.getLongitude(),
				user.getLatitude());
		List<StoreLocationModel> geoResults = geoRedisUtils.getRecStoreList(circle);

验证一下,GEO推荐的信息准确性

  1. 先随便创建了3个模拟坐标,如图
    三个门店地理位置
  2. 通过用户地理位置来获取附近门店排序列表(代码执行的效果如下)
georadius stores 121 31 10000 km WITHDIST WITHCOORD

返回结果已经排序,并且有具体距离信息

4.3 删除zset指定门店GEO信息

zrem stores 上海

zrem实现对GEO的删除操作

5. 总结

随着现在定位的越来越准,对应的软件推荐和用户体验追求越来越高,更加精准的用户定位和推荐都离不开技术支持。GEO是属于Redis3.2之后支持的功能,Redis在应用中解决的方法越来越高,本质上Redis并没有增加新的数据类型,而是利用了zset这个数据类型来实现。zset其实功能远不止于此,zset也可以用于用户Rank排名这样的功能,做到了大数据量,高响应,低耦合的特点。
当然,尽量不要Redis当做数据的最终落脚点,毕竟它在优点和缺点有明显的取舍。
当前方案应用于此小程序内

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://blog.wyatt.plus/?p=54

Buy me a cup of coffee ☕.