前言
我司的一个项目需要将地理数据存储到 MySQL 中,之前使用 MySQL 自带的空间数据类型来存储地理数据(POINT、LINESTRING、POLYGON),其自带的空间函数也可以比较方便地进行空间数据筛选。但需求说变就变(╥﹏╥...),现在又需要为每个点坐标加入三维信息(比如存储了一条线,构成线的每个点都需要有三个维度的坐标)。如果一个地理数据的三维信息是一致的,还可以新加一个字段来存储,但是每一个点的三维信息需要单独存储的话,MySQL 自带的空间数据类型就存储不了了。并且后续的空间范围筛选需要在二维的维度下进行(比如指定某条线以及缓冲区范围,在二维空间中,需要筛选出在缓冲区中的所有点、和缓冲区相交的所有线和面)。所以之前的实现全部要推翻重构,你说为什么一开始不说好呢,功能写了一半了才和我说,好像很好改一样 (╯︵╰)。
更改后的实现逻辑是这样:
- 在数据库表中新增一个字段
geometryType,用于标识几何类型点、线、面。 - 将地理数据存放在
VARCHAR类型的字段geometry中,用类似 Well-Known Text (WKT) 格式的字符串来存储地理数据,如线的存储:121.5312889779898 31.70429572226042 10,121.52181611362442 31.67929066337639 20,121.5288113599404 31.603770286693038 30。 - 写一个工具类,用于将字符串格式的空间数据转换成
JTS(org.locationtech.jts.geom)库中的Geometry对象(只转换其二维空间)。在进行空间查询时,可以直接调用JTS库的实现。 - 写一个工具类,用于将字符串格式的空间数据转换为
GeoJSON格式的字符串,方便返回给前端使用。
引入依赖
<!--jts-->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.20.0</version>
</dependency>
实体类
SpatialFeature:
@Data
@TableName("spatial_features")
@ApiModel(value = "三维地理数据对象", description = "三维地理数据表")
public class SpatialFeature {
@ApiModelProperty("主键ID")
private Long id;
@ApiModelProperty("几何类型:点、线、面")
private GeometryType geometryType;
@ApiModelProperty("三维地理数据")
private String geometry;
@ApiModelProperty("创建时间")
private LocalDateTime createdAt;
@ApiModelProperty("阶段ID")
private String prId;
public enum GeometryType {
POINT,
LINESTRING,
POLYGON
}
}
工具类
SpatialUtils,用于将字符串格式的三维空间数据转换成 JTS 的 Geometry 对象,只转换二维空间:
import lombok.experimental.UtilityClass;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.LinearRing;
@UtilityClass
public class SpatialUtils {
private static final GeometryFactory geometryFactory = new GeometryFactory();
public static boolean validateCoordinates(String coordinates) {
String regex = "^(-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?)(,\\s*-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?)*$";
return coordinates.matches(regex);
}
public static Point createPoint(String coordinates) {
try {
String[] parts = coordinates.trim().split("\\s+");
double x = Double.parseDouble(parts[0]);
double y = Double.parseDouble(parts[1]);
// 确保坐标在有效范围内
if (Math.abs(x) <= 180 && Math.abs(y) <= 90) {
return geometryFactory.createPoint(new Coordinate(x, y));
}
throw new IllegalArgumentException("Coordinates out of valid range");
} catch (Exception e) {
throw new IllegalArgumentException("Invalid coordinate format", e);
}
}
public static LineString createLineString(String coordinates) {
try {
String[] points = coordinates.split(",");
Coordinate[] coords = new Coordinate[points.length];
for (int i = 0; i < points.length; i++) {
String[] parts = points[i].trim().split("\\s+");
double x = Double.parseDouble(parts[0]);
double y = Double.parseDouble(parts[1]);
// 验证坐标范围
if (Math.abs(x) > 180 || Math.abs(y) > 90) {
throw new IllegalArgumentException("Coordinates out of valid range: " + x + ", " + y);
}
coords[i] = new Coordinate(x, y);
}
// 线至少需要两个点
if (coords.length < 2) {
throw new IllegalArgumentException("LineString must have at least 2 points");
}
return geometryFactory.createLineString(coords);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid LineString coordinates format", e);
}
}
public static Polygon createPolygon(String coordinates) {
try {
String[] points = coordinates.split(",");
Coordinate[] coords = new Coordinate[points.length + 1]; // 多一个点用于闭合多边形
// 构建坐标数组
for (int i = 0; i < points.length; i++) {
String[] parts = points[i].trim().split("\\s+");
double x = Double.parseDouble(parts[0]);
double y = Double.parseDouble(parts[1]);
// 验证坐标范围
if (Math.abs(x) > 180 || Math.abs(y) > 90) {
throw new IllegalArgumentException("Coordinates out of valid range: " + x + ", " + y);
}
coords[i] = new Coordinate(x, y);
}
// 多边形至少需要3个点
if (points.length < 3) {
throw new IllegalArgumentException("Polygon must have at least 3 points");
}
// 闭合多边形:最后一个点必须和第一个点相同
coords[coords.length - 1] = coords[0];
// 创建线性环(外环)
LinearRing ring = geometryFactory.createLinearRing(coords);
// 创建多边形(目前只支持无内环的简单多边形)
return geometryFactory.createPolygon(ring);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid Polygon coordinates format", e);
}
}
}
Spatial3DUtils,用于验证三维坐标格式:
import lombok.experimental.UtilityClass;
import java.util.regex.Pattern;
@UtilityClass
public class Spatial3DUtils {
// 验证三维坐标格式
private static final Pattern COORDINATE_PATTERN = Pattern.compile(
"^(-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?)(,\\s*-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?\\s+-?\\d+(\\.\\d+)?)*$"
);
public static boolean validateCoordinates(String coordinates) {
return COORDINATE_PATTERN.matcher(coordinates).matches();
}
}
GeoUtils,用于将字符串格式的空间数据转换为 GeoJSON 格式的字符串,并包装了 JTS 的空间查询方法:
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.experimental.UtilityClass;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Map;
@UtilityClass
public class GeoUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 将三维坐标字符串转换为 GeoJSON 字符串
* @param geometryStr 坐标字符串
* @param geometryType 几何类型
* @return GeoJSON 格式的字符串
*/
public static String convertToGeoJSONStr(String geometryStr, String geometryType) {
// 构建 GeoJSON 结构
StringBuilder json = new StringBuilder();
json.append("{\"type\":\"").append(geometryType).append("\",\"coordinates\":");
switch (geometryType) {
case "POINT":
// 处理点坐标: "x y z" -> [x,y,z]
json.append(convertPointCoordinates(geometryStr));
break;
case "LINESTRING":
// 处理线坐标: "x1 y1 z1,x2 y2 z2" -> [[x1,y1,z1],[x2,y2,z2]]
json.append(convertLineStringCoordinates(geometryStr));
break;
case "POLYGON":
// 处理面坐标: "x1 y1 z1,x2 y2 z2,x3 y3 z3" -> [[[x1,y1,z1],[x2,y2,z2],[x3,y3,z3],[x1,y1,z1]]]
json.append(convertPolygonCoordinates(geometryStr));
break;
default:
throw new IllegalArgumentException("Unsupported geometry type: " + geometryType);
}
json.append("}");
return json.toString();
}
/**
* 将三维坐标字符串转换为 GeoJSON Map 对象
* @param geometryStr 坐标字符串
* @param geometryType 几何类型
* @return GeoJSON 格式的 Map 对象
* @throws IOException
*/ public static Map<String, Object> convertToGeoJSON(String geometryStr, String geometryType) throws IOException {
String geoJsonStr = convertToGeoJSONStr(geometryStr, geometryType);
return objectMapper.readValue(geoJsonStr, Map.class);
}
private static String convertPointCoordinates(String coordStr) {
String[] coords = coordStr.trim().split("\\s+");
return String.format("[%s,%s,%s]", coords[0], coords[1], coords[2]);
}
private static String convertLineStringCoordinates(String coordStr) {
StringBuilder result = new StringBuilder("[");
String[] points = coordStr.split(",");
for (int i = 0; i < points.length; i++) {
if (i > 0) {
result.append(",");
}
result.append(convertPointCoordinates(points[i].trim()));
}
return result.append("]").toString();
}
private static String convertPolygonCoordinates(String coordStr) {
StringBuilder result = new StringBuilder("[[");
String[] points = coordStr.split(",");
// 添加所有点
for (int i = 0; i < points.length; i++) {
if (i > 0) {
result.append(",");
}
result.append(convertPointCoordinates(points[i].trim()));
}
// 如果第一个点和最后一个点不同,添加第一个点以闭合多边形
String firstPoint = points[0].trim();
String lastPoint = points[points.length - 1].trim();
if (!firstPoint.equals(lastPoint)) {
result.append(",").append(convertPointCoordinates(firstPoint));
}
return result.append("]]").toString();
}
/**
* 判断点是否在面内
* @param point 点
* @param polygon 面
* @return true 如果点在面内,false 否则
*/
public boolean isPointInPolygon(Point point, Polygon polygon) {
if (point == null || polygon == null) {
return false;
}
return polygon.contains(point);
}
/**
* 判断点是否在线的缓冲区内
* @param point 点
* @param line 线
* @param bufferDistance 缓冲区距离
* @return true如果点在线的缓冲区内,false否则
*/
public boolean isPointInLineBuffer(Point point, LineString line, double bufferDistance) {
if (point == null || line == null || bufferDistance <= 0) {
return false;
}
// 创建线的缓冲区
Geometry buffer = line.buffer(bufferDistance);
return buffer.contains(point);
}
/**
* 判断线是否与另一条线的缓冲区相交
* @param line1 第一条线
* @param line2 第二条有缓冲区的线
* @param bufferDistance 缓冲区距离
* @return true如果线与另一条线的缓冲区相交,false否则
*/
public static boolean isLineIntersectLineBuffer(LineString line1, LineString line2, double bufferDistance) {
if (line1 == null || line2 == null || bufferDistance <= 0) {
return false;
}
// 创建第二条线的缓冲区
Geometry buffer = line2.buffer(bufferDistance);
return buffer.intersects(line1);
}
/**
* 判断面是否与线的缓冲区相交
*/
public boolean isPolygonIntersectLineBuffer(Polygon polygon, LineString line, double bufferDistance) {
if (line == null || polygon == null || bufferDistance <= 0) {
return false;
}
// 创建线的缓冲区
Geometry buffer = line.buffer(bufferDistance);
return buffer.intersects(polygon);
}
/**
* 判断线是否与面相交
* @param line 线
* @param polygon 面
* @return true如果线与面相交,false否则
*/
public boolean isLineIntersectPolygon(LineString line, Polygon polygon) {
if (line == null || polygon == null) {
return false;
}
return line.intersects(polygon);
}
/**
* 判断两个面是否相交
* @param polygon1 第一个面
* @param polygon2 第二个面
* @return true如果两个面相交,false否则
*/
public boolean isPolygonIntersectPolygon(Polygon polygon1, Polygon polygon2) {
if (polygon1 == null || polygon2 == null) {
return false;
}
return polygon1.intersects(polygon2);
}
/**
* 判断点是否在点的缓冲区内
* @param point1 第一个点
* @param point2 第二个有缓冲区的点
* @param bufferDistance 缓冲区距离
*/
public boolean isPointInPointBuffer(Point point1, Point point2, double bufferDistance) {
if (point1 == null || point2 == null || bufferDistance <= 0) {
return false;
}
// 创建第二个点的缓冲区
Geometry buffer = point2.buffer(bufferDistance);
return buffer.contains(point1);
}
/**
* 判断线是否与点的缓冲区相交
* @param line 线
* @param point 有缓冲区的点
* @param bufferDistance 缓冲区距离
*/
public boolean isLineIntersectPointBuffer(LineString line, Point point, double bufferDistance) {
if (line == null || point == null || bufferDistance <= 0) {
return false;
}
// 创建点的缓冲区
Geometry buffer = point.buffer(bufferDistance);
return buffer.intersects(line);
}
/**
* 判断面是否与点的缓冲区相交
* @param polygon 面
* @param point 有缓冲区的点
* @param bufferDistance 缓冲区距离
*/
public boolean isPolygonIntersectPointBuffer(Polygon polygon, Point point, double bufferDistance) {
if (polygon == null || point == null || bufferDistance <= 0) {
return false;
}
// 创建点的缓冲区
Geometry buffer = point.buffer(bufferDistance);
return buffer.intersects(polygon);
}
/**
* 判断几何对象是否有效
* @param geometry 几何对象
* @return true如果几何对象有效,false否则
*/
public boolean isValid(Geometry geometry) {
return geometry != null && geometry.isValid();
}
}
具体服务实现
SpatialFeatureServiceImpl:
@Slf4j
@Service
@Transactional
public class SpatialFeatureServiceImpl
extends ServiceImpl<SpatialFeatureMapper, SpatialFeature>
implements SpatialFeatureService {
@Override
public boolean saveGeometryData(SpatialFeature.GeometryType type, SpatialRequest request) {
if (!Spatial3DUtils.validateCoordinates(request.getCoordinates())) {
throw new IllegalArgumentException(
String.format("Invalid 3D %s coordinates format", type.name().toLowerCase())
);
}
try {
SpatialFeature feature = new SpatialFeature();
feature.setGeometryType(type);
feature.setGeometry(request.getCoordinates());
feature.setPrId(request.getPrId());
feature.setCreatedAt(LocalDateTime.now());
return save(feature);
} catch (Exception e) {
log.error("Error saving 3D {}: {}", type.name().toLowerCase(), e.getMessage());
throw new RuntimeException("Failed to save 3D " + type.name().toLowerCase(), e);
}
}
}
SpatialFeatureController:
@Api(tags = "项目对象管理(项目、项目阶段、地理数据)")
@RestController
@RequestMapping("/common/project/feature")
public class SpatialFeatureController {
@Resource
private ProjectStageService stageService;
@Resource
private ProjectNewService projectService;
@Autowired
private SpatialFeatureService spatialFeatureService;
@ApiOperation("新建项目、项目阶段、地理数据")
@RequiresPermissions
@PostMapping("add")
@Transactional(rollbackFor = Exception.class)
@Log(title = "项目管理", businessType = BusinessType.INSERT)
public AjaxResult addProjectAndStage(@RequestBody ProjectAndStageRequest request) {
ProjectNew project = request.getProject();
String projectGuid = Optional.ofNullable(project.getProjectGuid())
.orElseGet(() -> {
project.setCreateBy(SecurityUtils.getUsername());
project.setCreateTime(new Date());
return projectService.createAndReturnId(project);
});
// 创建项目阶段
ProjectStage stage = request.getStage();
stage.setCreateTime(String.valueOf(new Date()));
stage.setCreateBy(SecurityUtils.getUsername());
stage.setProjectGuid(projectGuid);
String stageId = stageService.createStageAndReturnId(
stage, project.getProjectName(), project.getProjectShortname()
);
// 处理地理数据
Optional.ofNullable(request.getGeoData())
.filter(data -> request.getGeoType() != null)
.ifPresent(data -> {
SpatialRequest spatialRequest = new SpatialRequest();
spatialRequest.setCoordinates(data);
spatialRequest.setPrId(stageId);
spatialFeatureService.saveGeometryData(request.getGeoType(), spatialRequest);
});
return AjaxResult.success();
}
// 获取所有项目阶段的地理数据
@ApiOperation("获取所有地理数据")
@RequiresPermissions
@PostMapping("list")
public R<List<SpatialFeatureResponse>> listProjectStageGeoData() throws IOException {
List<SpatialFeatureResponse> res = new ArrayList<>();
List<SpatialFeature> list = spatialFeatureService.list();
for (SpatialFeature spatialFeature : list) {
String geometry = spatialFeature.getGeometry();
String prId = spatialFeature.getPrId();
Map<String, Object> stringObjectMap = GeoUtils.convertToGeoJSON(geometry, spatialFeature.getGeometryType().name());
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
}
return R.ok(res);
}
@ApiOperation("根据项目信息筛选地理数据")
@RequiresPermissions
@PostMapping("listByProject")
public R<List<SpatialFeatureResponse>> listByProject(@RequestBody QueryGeoRelationRequest request) throws IOException {
List<SpatialFeatureResponse> res = new ArrayList<>();
List<SpatialFeature> list = spatialFeatureService.list();
for (SpatialFeature spatialFeature : list) {
String geometry = spatialFeature.getGeometry();
String prId = spatialFeature.getPrId();
Map<String, Object> stringObjectMap = GeoUtils.convertToGeoJSON(geometry, spatialFeature.getGeometryType().name());
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
}
return R.ok(filterByProjectInfo(res, request));
}
// 获取在面内的点,与面相交的线和面
@ApiOperation("根据面和项目信息筛选地理数据")
@RequiresPermissions
@PostMapping("queryPolyRelation")
public R<List<SpatialFeatureResponse>> getPolyRelation(@RequestBody QueryGeoRelationRequest request) throws IOException {
List<SpatialFeatureResponse> res = new ArrayList<>();
// 将 geoFeatureStr 转换为 Geometry 对象
Polygon polygon = createPolygon(request.getGeoFeatureStr());
List<SpatialFeature> spatialFeatures = spatialFeatureService.list();
for (SpatialFeature spatialFeature : spatialFeatures) {
// 获取 Geometry 对象
String geometry = spatialFeature.getGeometry();
// 获取 prId String prId = spatialFeature.getPrId();
Map<String, Object> stringObjectMap = GeoUtils.convertToGeoJSON(geometry, spatialFeature.getGeometryType().name());
SpatialFeature.GeometryType geometryType = spatialFeature.getGeometryType();
switch (geometryType) {
case POINT:
Point point = createPoint(geometry);
if (GeoUtils.isPointInPolygon(point, polygon)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case LINESTRING:
LineString lineString = createLineString(geometry);
if (GeoUtils.isLineIntersectPolygon(lineString, polygon)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case POLYGON:
Polygon polygon2 = createPolygon(geometry);
if (GeoUtils.isPolygonIntersectPolygon(polygon2, polygon)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
}
}
return R.ok(filterByProjectInfo(res, request));
}
// 获取在线的缓冲区内的点、与线的缓冲区相交的线和面
@ApiOperation("根据线的缓冲区和项目信息筛选地理数据")
@RequiresPermissions
@PostMapping("queryLineBufferRelation")
public R<List<SpatialFeatureResponse>> getLineBufferRelation(@RequestBody QueryGeoRelationRequest request) throws IOException {
List<SpatialFeatureResponse> res = new ArrayList<>();
// 将 geoFeatureStr 转换为 Geometry 对象
// 将 "120 30, 121 31, 122 32_50" 分隔成线的字符串和缓冲区距离
String[] str_arr = request.getGeoFeatureStr().split("_");
LineString line = createLineString(str_arr[0]);
double bufferDistance = Double.parseDouble(str_arr[1]);
List<SpatialFeature> spatialFeatures = spatialFeatureService.list();
for (SpatialFeature spatialFeature : spatialFeatures) {
// 获取 Geometry 对象
String geometry = spatialFeature.getGeometry();
// 获取 prId String prId = spatialFeature.getPrId();
Map<String, Object> stringObjectMap = GeoUtils.convertToGeoJSON(geometry, spatialFeature.getGeometryType().name());
SpatialFeature.GeometryType geometryType = spatialFeature.getGeometryType();
switch (geometryType) {
case POINT:
Point point = createPoint(geometry);
if (GeoUtils.isPointInLineBuffer(point, line, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case LINESTRING:
LineString lineString = createLineString(geometry);
if (GeoUtils.isLineIntersectLineBuffer(lineString, line, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case POLYGON:
Polygon polygon = createPolygon(geometry);
if (GeoUtils.isPolygonIntersectLineBuffer(polygon, line, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
}
}
return R.ok(filterByProjectInfo(res, request));
}
// 获取在点的缓冲区内的点、与点的缓冲区相交的线和面
@ApiOperation("根据点的缓冲区和项目信息筛选地理数据")
@RequiresPermissions
@PostMapping("queryPointBufferRelation")
public R<List<SpatialFeatureResponse>> getPointBufferRelation(@RequestBody QueryGeoRelationRequest request) throws IOException {
List<SpatialFeatureResponse> res = new ArrayList<>();
// 将 geoFeatureStr 转换为 Geometry 对象
// 将 "120 30_50" 分隔成点的字符串和缓冲区距离
String[] str_arr = request.getGeoFeatureStr().split("_");
Point point = createPoint(str_arr[0]);
double bufferDistance = Double.parseDouble(str_arr[1]);
List<SpatialFeature> spatialFeatures = spatialFeatureService.list();
for (SpatialFeature spatialFeature : spatialFeatures) {
// 获取 Geometry 对象
String geometry = spatialFeature.getGeometry();
// 获取 prId String prId = spatialFeature.getPrId();
Map<String, Object> stringObjectMap = GeoUtils.convertToGeoJSON(geometry, spatialFeature.getGeometryType().name());
SpatialFeature.GeometryType geometryType = spatialFeature.getGeometryType();
switch (geometryType) {
case POINT:
Point point2 = createPoint(geometry);
if (GeoUtils.isPointInPointBuffer(point2, point, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case LINESTRING:
LineString lineString = createLineString(geometry);
if (GeoUtils.isLineIntersectPointBuffer(lineString, point, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
case POLYGON:
Polygon polygon = createPolygon(geometry);
if (GeoUtils.isPolygonIntersectPointBuffer(polygon, point, bufferDistance)) {
res.add(new SpatialFeatureResponse(prId, stringObjectMap));
break;
}
break;
}
}
return R.ok(filterByProjectInfo(res, request));
}
}