MongoDB GEO 项目场景 ms-scope 实战
大家好,我是此林。
关于 MongoDB,我们在这篇文章中已经详细讲述了其基本增删改查、SpringBoot 整合等内容。一文快速入门 MongoDB 、MongoDB 8.2 下载安装、增删改查操作、索引、SpringBoot整合 Spring Data MongoDB
今天我们分享的是 MongoDB GEO 项目场景实战。
1. 场景描述
小王在某物流平台上下单,系统需要快速找到能为他服务的快递员。通过对比小王的位置与快递员的服务范围,平台能准确匹配到合适人员,解决了订单分配效率低、匹配不精准的问题。
那系统具体怎么做呢?
首先快递网点会有个作业范围,比如图中红色框标注的多边形,一个由多个坐标点组成的多边形,并且必须是闭合的多边形。小王位置为灰色的竖线。
这个就比较适合用MongoDB来存储。
用户小王下了订单,如何找到属于该服务范围内的快递员呢?我们使用MongoDB的$geoIntersects
查询操作,其原理就是查找小王的位置坐标点与哪个多边形有交叉,这个就是为其服务的快递员。
2. 定义 MongoDB 实体
机构和快递员的作业范围逻辑一般是一样的,所以可以共存一张表中,通过type进行区分,1-机构,2-快递员。
/*** 服务范围实体*/
@Data
@Document("service_scope")
public class ServiceScopeEntity {@Id@JsonIgnoreprivate ObjectId id;/*** 业务id,可以是机构或快递员*/@Indexedprivate Long bid;/*** 类型 {@link com.ms.scope.enums.ServiceTypeEnum}*/@Indexedprivate Integer type;/*** 多边形范围,是闭合的范围,开始经纬度与结束经纬度必须一样* x: 经度,y:纬度*/@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)private GeoJsonPolygon polygon;private Long created; //创建时间private Long updated; //更新时间
}
这里面的 @Document("service_scope") 指定 MongoDB 集合名称(类似于关系型数据库中的表名),里面的每条记录对应一片服务区域。
id:ObjectId 类型。ObjectId 是 MongoDB 内置的唯一标识类型,@Id 表示该字段是主键,@JsonIgnore 表示序列化为 JSON 时忽略该字段(对外不暴露)。
bid:某个网点 ID,或某个快递员的 ID, @Indexed 表示该字段会在 MongoDB 中建立索引,加快查询速度。
type:1=网点,2=快递员,也加了索引,提高查询效率。
polygon:GeoJsonPolygon 类型。用 GeoJsonPolygon 来存储一个多边形(由多个经纬度点组成)。多边形必须闭合,即起点和终点的经纬度必须相同。@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE) 用于建立地理空间索引(GEO_2DSPHERE)。
created:创建时间(时间戳,毫秒值)
updated:更新时间(时间戳,毫秒值)
这里再贴一下服务类型枚举:
import cn.hutool.core.util.EnumUtil;/*** 服务类型枚举*/
public enum ServiceTypeEnum {ORGAN(1, "机构"),COURIER(2, "快递员");/*** 类型编码*/private final Integer code;/*** 类型值*/private final String value;ServiceTypeEnum(Integer code, String value) {this.code = code;this.value = value;}public Integer getCode() {return code;}public String getValue() {return value;}public static ServiceTypeEnum codeOf(Integer code) {return EnumUtil.getBy(ServiceTypeEnum::getCode, code);}
}
3. ScopeService 接口编写
我们 Service 要编写哪些方法呢?
主要有下面几个:
1. 新增或更新服务范围。即新增或更新服务范围的多边形各个顶点坐标。
2. 根据主键id、业务id、类型删除+查询服务范围
3. 根据坐标点查询所属哪个服务范围。这个坐标点就是用户位置坐标。
4. 根据用户详细地址查询所属的服务范围。
所以我们定义如下接口:
/*** 服务范围Service*/
public interface ScopeService {/*** 新增或更新服务范围** @param bid 业务id* @param type 类型* @param polygon 多边形坐标点* @return 是否成功*/Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon);/*** 根据主键id删除数据** @param id 主键* @return 是否成功*/Boolean delete(String id);/*** 根据业务id和类型删除数据** @param bid 业务id* @param type 类型* @return 是否成功*/Boolean delete(Long bid, ServiceTypeEnum type);/*** 根据主键查询数据** @param id 主键* @return 服务范围数据*/ServiceScopeEntity queryById(String id);/*** 根据业务id和类型查询数据** @param bid 业务id* @param type 类型* @return 服务范围数据*/ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type);/*** 根据坐标点查询所属的服务对象** @param type 类型* @param point 坐标点* @return 服务范围数据*/List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point);/*** 根据详细地址查询所属的服务对象** @param type 类型* @param address 详细地址,如:石家庄市桥西区XXX小区XX号楼XXX室* @return 服务范围数据*/List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address);
}
4. ScopeController API 编写
@Api(tags = "服务范围")
@RestController
@RequestMapping("/scopes")
@Validated
public class ScopeController {@Resourceprivate ScopeService scopeService;/*** 新增或更新服务服务范围** @return REST标准响应*/@ApiOperation(value = "新增/更新", notes = "新增或更新服务服务范围")@PostMappingpublic ResponseEntity<Void> saveScope(@RequestBody ServiceScopeDTO serviceScopeDTO) {ServiceScopeEntity serviceScopeEntity = EntityUtils.toEntity(serviceScopeDTO);Long bid = serviceScopeEntity.getBid();ServiceTypeEnum type = ServiceTypeEnum.codeOf(serviceScopeEntity.getType());Boolean result = this.scopeService.saveOrUpdate(bid, type, serviceScopeEntity.getPolygon());if (result) {return ResponseEntityUtils.ok();}return ResponseEntityUtils.error();}/*** 删除服务范围** @param bid 业务id* @param type 类型* @return REST标准响应*/@ApiImplicitParams({@ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)})@ApiOperation(value = "删除", notes = "删除服务范围")@DeleteMapping("{bid}/{type}")public ResponseEntity<Void> delete(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,@NotNull(message = "type不能为空") @PathVariable("type") Integer type) {Boolean result = this.scopeService.delete(bid, ServiceTypeEnum.codeOf(type));if (result) {return ResponseEntityUtils.ok();}return ResponseEntityUtils.error();}/*** 查询服务范围** @param bid 业务id* @param type 类型* @return 服务范围数据*/@ApiImplicitParams({@ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)})@ApiOperation(value = "查询", notes = "查询服务范围")@GetMapping("{bid}/{type}")public ResponseEntity<ServiceScopeDTO> queryServiceScope(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,@NotNull(message = "type不能为空") @PathVariable("type") Integer type) {ServiceScopeEntity serviceScopeEntity = this.scopeService.queryByBidAndType(bid, ServiceTypeEnum.codeOf(type));return ResponseEntityUtils.ok(EntityUtils.toDTO(serviceScopeEntity));}/*** 地址查询服务范围** @param type 类型,1-机构,2-快递员* @param address 详细地址,如:北京市昌平区金燕龙办公楼传智教育总部* @return 服务范围数据列表*/@ApiImplicitParams({@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),@ApiImplicitParam(name = "address", value = "详细地址,如:北京市昌平区金燕龙办公楼传智教育总部", dataTypeClass = String.class)})@ApiOperation(value = "地址查询服务范围", notes = "地址查询服务范围")@GetMapping("address")public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,@NotNull(message = "address不能为空") @RequestParam("address") String address) {List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), address);return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));}/*** 位置查询服务范围** @param type 类型,1-机构,2-快递员* @param longitude 经度* @param latitude 纬度* @return 服务范围数据列表*/@ApiImplicitParams({@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),@ApiImplicitParam(name = "longitude", value = "经度", dataTypeClass = Double.class),@ApiImplicitParam(name = "latitude", value = "纬度", dataTypeClass = Double.class)})@ApiOperation(value = "位置查询服务范围", notes = "位置查询服务范围")@GetMapping("location")public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,@NotNull(message = "longitude不能为空") @RequestParam("longitude") Double longitude,@NotNull(message = "latitude不能为空") @RequestParam("latitude") Double latitude) {List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), new GeoJsonPoint(longitude, latitude));return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));}
}
Spring MVC 的 @RequestParam
或 @PathVariable
默认不会自动生成 Swagger 文档里参数的详细信息。
@ApiImplicitParam
是 Swagger(API 文档生成工具) 的注解,用于描述接口的请求参数,让 Swagger UI 能够自动生成可交互的 API 文档。
name
:参数名,对应接口方法里的@RequestParam
或@PathVariable
名称。value
:参数说明,会显示在 Swagger 文档里。dataTypeClass
:参数的数据类型(如Integer.class
、String.class
)。- 还可以写
required = true
表示必填。
5. ScopeServiceImpl 实现
1. 删除或更新服务范围
@Overridepublic Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon) {Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件ServiceScopeEntity serviceScopeEntity = this.mongoTemplate.findOne(query, ServiceScopeEntity.class);if (ObjectUtil.isEmpty(serviceScopeEntity)) {//新增serviceScopeEntity = new ServiceScopeEntity();serviceScopeEntity.setBid(bid);serviceScopeEntity.setType(type.getCode());serviceScopeEntity.setPolygon(polygon);serviceScopeEntity.setCreated(System.currentTimeMillis());serviceScopeEntity.setUpdated(serviceScopeEntity.getCreated());} else {//更新serviceScopeEntity.setPolygon(polygon);serviceScopeEntity.setUpdated(System.currentTimeMillis());}try {this.mongoTemplate.save(serviceScopeEntity);return true;} catch (Exception e) {log.error("新增/更新服务范围数据失败! bid = {}, type = {}, points = {}", bid, type, polygon.getPoints(), e);}return false;}
2. 删除服务范围
@Overridepublic Boolean delete(String id) {Query query = Query.query(Criteria.where("id").is(new ObjectId(id))); //构造查询条件return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;}@Overridepublic Boolean delete(Long bid, ServiceTypeEnum type) {Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;}
3. 基础查询
@Overridepublic ServiceScopeEntity queryById(String id) {return this.mongoTemplate.findById(new ObjectId(id), ServiceScopeEntity.class);}@Overridepublic ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type) {Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件return this.mongoTemplate.findOne(query, ServiceScopeEntity.class);}
4. 根据用户经纬度坐标查询所属哪个服务范围
@Overridepublic List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point) {Query query = Query.query(Criteria.where("polygon").intersects(point).and("type").is(type.getCode()));return this.mongoTemplate.find(query, ServiceScopeEntity.class);}
GeoJsonPoint point
是用户传过来的经纬度坐标,比如:new GeoJsonPoint(116.395645, 39.929986) // 北京天安门
Criteria.where("polygon").intersects(point)
polygon
是我们之前在ServiceScopeEntity
中定义的服务范围多边形(闭合区域)。
intersects(point)
的意思是:查询所有polygon
多边形包含该点的记录MongoDB 的地理空间查询会自动判断点是否落在多边形内部。
5. 根据详细地址查询坐标
@Overridepublic List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address) {//根据详细地址查询坐标GeoResult geoResult = this.mapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);Coordinate coordinate = geoResult.getLocation();return this.queryListByPoint(type, new GeoJsonPoint(coordinate.getLongitude(), coordinate.getLatitude()));}
这里我们要调用地图服务的接口(比如高德或百度地图服务)。只要传入地址,高德或百度都可以返回经纬度。具体接口调用可以看看官方文档。
6. 测试
@SpringBootTest
public class ScopeServiceTest {@Resourceprivate ScopeService scopeService;@Testvoid saveOrUpdate() {List<Point> pointList = Arrays.asList(new Point(116.340064,40.061245),new Point(116.347081,40.061836),new Point(116.34751,40.05842),new Point(116.342446,40.058092),new Point(116.340064,40.061245));Boolean result = this.scopeService.saveOrUpdate(2L, ServiceTypeEnum.ORGAN, new GeoJsonPolygon(pointList));System.out.println(result);}@Testvoid testQueryListByPoint() {GeoJsonPoint point = new GeoJsonPoint(116.344828,40.05911);List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, point);serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));}@Testvoid testQueryListByPoint2() {String address = "石家庄市桥西区永德小区2号楼802室";List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, address);serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));}
}
最终返回类似如下的匹配到的服务范围。
7. 其他思考点
其实目前代码所做,基本上是确定出用户被分派给哪个机构,后续还需要把派件任务委派给快递员。
确定好了起始的 网点,还需要确定终点网点。
同样的,取 收件人地址 → 转坐标 (lng, lat),
查询 MongoDB:找哪个机构的服务范围包含这个点,结果 = 终点网点。
中途还会有 路线规划(用于调度),起点网点 → 中转中心(多级)→ 终点网点。这些后续会介绍。
为什么会用 MongoDB 存储服务范围?
- 快递员、网点、机构都有“服务范围”,这些范围通常是多边形(polygon)。
- 如果用 MySQL 来存:我们可能想到用 JSON 字符串存经纬度点,但是查询时需要自己写数学算法判断点是否在多边形内,比较复杂且慢。
- MongoDB 支持 GeoJSON 和 地理空间索引,自带点、多边形、圆形的查询能力,查询效率高。
常见应用:
- “附近的餐馆/快递员/加油站”
- “这个地址属于哪个网点”
- “用户当前位置在不在配送范围”
所以,这类“地理空间场景”适合 MongoDB,既然有开箱即用的工具为啥不用呢?
不过,话虽然这么说。
MySQL 从 5.7 开始其实也支持 GIS,可以存储 POINT
、POLYGON
等几何类型。除了 MySQL、MongoDB,还有其他的技术实现对比:
技术 | 支持点查询 | 支持多边形 | 性能 | 适用场景 |
---|---|---|---|---|
MongoDB | ✅ | ✅ | 中等 | 普通互联网业务,范围查询、附近的人 |
MySQL GIS | ✅ | ✅ | 一般 | 小规模场景,已有 MySQL 就能用 |
PostGIS | ✅ | ✅ | 高 | 需要复杂地理计算的大型系统 |
Elasticsearch | ✅ | ✅ | 高 | 搜索 + 地理混合场景 |
Redis GEO | ✅ | ❌(无多边形) | 超高 | 附近的人,高并发场景 |
Redis GEO 性能超高的原因还是主要是因为它是内存计算。
不管哪种技术选型,适合自己的业务才是王道,项目里之所以用 MongoDB 其实也是因为 MongoDB 不仅仅用于地理范围搜索,还有其他用途。
当然比如说项目里有百万千万级别的全文搜索 + 地理位置搜索场景,用 Elasticsearch 也是可以的。
今天的分享就到这里了。
我是此林,关注我吧!带你看不一样的世界!