第23章 - JTS 与上下游技术栈集成

单独使用 JTS 的场景其实不多。绝大多数时候,JTS 是技术栈中间的一层:上面是 GeoTools、GeoServer、Spring Boot 这类框架,下面是 PostGIS、Elasticsearch、Sedona 这类存储与计算引擎,两边都自称“支持 JTS 几何”,但支持的方式、版本、语义各不相同。写出事故的往往不是 JTS 本身,而是跨栈边界上的语义差异——同一个 ST_Buffer 在 PostGIS 里按米算,在 JTS 里按坐标单位算;同一个 isValid 在 GEOS 与 JTS 上对某些自相交图形给出不同结论;同一个 GeoJSON 在 Elasticsearch 里是 geo_shape,在 Shapely 里是 Geometry,在 Hibernate 里要经过 GeometryType 映射。

本章的目标是把这张“JTS 生态地图”画清楚:谁依赖 JTS、谁不依赖、各自暴露什么 API、跨栈迁移时哪些地方会咬人。我们不追求把每个框架讲全(那需要另一套教程),而是聚焦与 JTS 的接口层:数据怎么进来、怎么出去、语义怎么对齐。

读完后你应该能:在 GeoTools 里正确拿到 GeometryFactory 并做 CRS 转换;把 JTS 调用翻译成 PostGIS 的 ST_*;判断某个栈到底用不用 JTS;在 Spring Boot 项目里把 JTS 依赖与线程安全安排好;以及看懂版本兼容矩阵,避免“本地 1.20、服务器 1.18”这种事故。

前置知识:第 6 章(WKT 与 WKB)、第 7 章(GeoJSON 与 TWKB)、第 19 章(坐标系、单位与工程陷阱)。


23.1 JTS 在技术栈中的位置

23.1.1 一张分层图

┌──────────────────────────────────────────────────────────────────┐
│ 应用层     Spring Boot / 自研服务 / 批处理作业                     │
├──────────────────────────────────────────────────────────────────┤
│ 框架层     GeoTools   GeoServer   Hibernate Spatial   Sedona      │
│            (这些框架内部依赖 JTS,并向上暴露自己的 API)           │
├──────────────────────────────────────────────────────────────────┤
│ 几何引擎层  JTS (Java)     GEOS (C++)     NTS (.NET)   JSTS (JS)   │
│            ↑ 同源算法思想,各自独立实现(GEOS 是 JTS 的 C++ 移植)  │
├──────────────────────────────────────────────────────────────────┤
│ 存储/检索   PostGIS(GEOS)  Elasticsearch(Lucene)  Oracle Spatial   │
│            MongoDB(GeoJSON)  Sedona(Spark + JTS)                   │
└──────────────────────────────────────────────────────────────────┘

这张图里最重要的一条结论:JTS 只活在 JVM 上。凡是 JVM 之外的地方(PostGIS 的 C 函数、NTS 的 C# 类、JSTS 的 JS 对象、Shapely 的 Python 包装),它们要么是 JTS 算法的移植(GEOS、NTS、JSTS),要么是完全独立的实现(Lucene 的 spatial 模块)。它们之间共享算法思想与 WKT/WKB 格式,但不共享二进制、不共享运行时。

23.1.2 三个必须记住的边界事实

事实 影响 应对
JTS 不含 CRS 所有距离、面积、缓冲的单位都是“坐标单位” 投影前置,或改用 GeoTools/Proj4J 做转换
PostGIS 用 GEOS,不用 JTS 同名函数在边界情况可能结果不同 用 WKB 交换 + 关键用例双端验证
各栈的“有效性”定义有细微差异 一个栈认为合法的几何,另一个可能拒绝 入库前用目标栈的校验函数复核

23.1.3 数据交换的三种载体

跨栈传几何,实际只有三种可靠载体:

JTS Geometry ──WKB(hex)──> PostGIS bytea / Elasticsearch binary / 文件
             ──WKT──────> SQL 字面量 / 日志 / 测试用例
             ──GeoJSON──> 前端 / ES geo_shape / MongoDB
载体 精度 体积 跨栈友好度 适用场景
WKB 双精度无损 最小 最好(所有栈都支持) 服务间传输、入库
WKT 双精度但文本有舍入风险 大 好(人可读) 调试、SQL 字面量、测试
GeoJSON 双精度,但默认 6 位小数 中 好(Web 生态) 前后端交互、地图展示

关键细节:JTS 的 GeoJsonWriter 默认输出保留了完整双精度,但很多前端库与 ES 会按 6 位小数处理。做高精度业务(地籍、管线)时,Web 层用 GeoJSON 展示、内部用 WKB 传输,是最稳的组合。


23.2 GeoTools 集成

23.2.1 GeoTools 与 JTS 的关系

GeoTools 是 JTS 最重要的下游:它把 JTS 当作唯一的几何引擎,所有 SimpleFeature 的几何属性都是 org.locationtech.jts.geom.Geometry。两者版本强绑定——GeoTools 的每个版本都锁定一个 JTS 版本,混用会出现 NoSuchMethodError。

关注点 怎么确认 说明
锁定的 JTS 版本 mvn dependency:tree -Dincludes=org.locationtech.jts GeoTools 每个版本只对应一个 JTS 版本
接口包的前缀 看 import 是 org.geotools.* 还是 org.geotools.api.* 较新的 GeoTools 把接口拆到了 org.geotools.api.*
是否允许覆盖 JTS 看 GeoTools BOM 的 dependencyManagement 手动升级 JTS 极易导致 NoSuchMethodError

唯一权威来源是 GeoTools 自己的 BOM 与发布说明。不要凭记忆锁版本,也不要在使用 GeoTools 的项目里单独升级 jts-core——两者的二进制契约比版本号看起来的更紧。

23.2.2 拿到正确的 GeometryFactory

在 GeoTools 环境里不要自己 new GeometryFactory(),因为 GeoTools 会配置精度模型与坐标序列工厂。正确做法是用 JTSFactoryFinder:

import org.geotools.geometry.jts.JTSFactoryFinder;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class GeoToolsGeometryFactoryDemo {

  public static void main(String[] args) throws Exception {
    // 由 GeoTools 提供、按当前运行环境配置好的工厂
    GeometryFactory gf = JTSFactoryFinder.getGeometryFactory();

    WKTReader reader = new WKTReader(gf);
    Geometry geom = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");

    System.out.println("SRID = " + geom.getSRID());
    System.out.println("area = " + geom.getArea());
    System.out.println("isValid = " + geom.isValid());
  }
}

JTSFactoryFinder.getGeometryFactory() 不带参数时返回默认工厂;也可以传入 Hints(如 Hints.CRS、Hints.JTS_PRECISION_MODEL)来定制。注意它返回的工厂通常不带 SRID——SRID 要显式设到几何上(geom.setSRID(4326)),或者由 FeatureType 的 CRS 提供。

23.2.3 CRS 转换:JTS.transform

JTS 本身不做投影变换,这一步必须交给 GeoTools:

import org.geotools.geometry.jts.JTS;
import org.geotools.referencing.CRS;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.MathTransform;

public class CrsTransformDemo {

  public static void main(String[] args) throws Exception {
    GeometryFactory gf = new GeometryFactory();
    WKTReader reader = new WKTReader(gf);

    Geometry wgs84 = reader.read("POINT (116.397 39.909)");
    wgs84.setSRID(4326);

    CoordinateReferenceSystem src = CRS.decode("EPSG:4326", true);
    CoordinateReferenceSystem dst = CRS.decode("EPSG:3857", true);
    MathTransform transform = CRS.findMathTransform(src, dst, true);

    // 几何整体变换
    Geometry webMercator = JTS.transform(wgs84, transform);
    System.out.println(webMercator);

    // 单点变换(更轻量,但要注意 null 参数的位置)
    Coordinate srcCoord = new Coordinate(116.397, 39.909);
    Coordinate dstCoord = JTS.transform(srcCoord, null, transform);
    System.out.println(dstCoord);

    // 反向变换
    MathTransform inverse = transform.inverse();
    Geometry back = JTS.transform(webMercator, inverse);
    System.out.println(back);
  }
}

输出(按 EPSG:3857 球面墨卡托公式 x = lon × R × π/180、y = R × ln(tan(π/4 + lat × π/360)) 计算,R = 6378137):

POINT (12957254.770 4852727.206)
(12957254.770..., 4852727.206...)
POINT (116.397 39.909)

反向变换回到 WGS84 后与原始坐标的差异在 1e-9 量级,这是双精度浮点的正常往返误差。

三个注意点:

  1. CRS.decode("EPSG:4326", true) 的第二个参数是 longitudeFirst。传 true 表示“经度在前”,符合 GIS 惯例;传 false 会得到纬度在前,然后所有坐标都错位。这个参数是 GeoTools 里最高频的事故源之一。
  2. JTS.transform(Geometry, MathTransform) 返回新几何,不修改输入。但内部的坐标数组可能是共享的,别在变换后复用原几何做写入。
  3. 变换后的几何可能不再“有效”:反投影、跨带变换都可能产生自相交。关键业务在变换后补一次 GeometryFixer.fix 或 IsValidOp 校验。

23.2.4 Feature 与几何属性

GeoTools 的 SimpleFeature 把几何作为普通属性存储,构建要素类型时要显式声明几何字段的 CRS:

import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.referencing.CRS;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;

public class FeatureDemo {

  public static void main(String[] args) throws Exception {
    GeometryFactory gf = new GeometryFactory();
    WKTReader reader = new WKTReader(gf);

    SimpleFeatureTypeBuilder tb = new SimpleFeatureTypeBuilder();
    tb.setName("parcel");
    tb.setCRS(CRS.decode("EPSG:4326", true));
    tb.add("geom", Geometry.class);          // 默认几何字段名
    tb.add("code", String.class);
    tb.add("area", Double.class);
    SimpleFeatureType type = tb.buildFeatureType();

    Geometry polygon = reader.read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))");
    polygon.setSRID(4326);

    SimpleFeature feature = SimpleFeatureBuilder.build(type, new Object[] {
        polygon, "A-001", polygon.getArea()
    }, "parcel.1");

    System.out.println(feature.getID());
    System.out.println(feature.getDefaultGeometry());
    System.out.println(feature.getAttribute("code"));
  }
}

SimpleFeatureTypeBuilder 的 setCRS 只影响元数据,不会自动给几何设 SRID。如果你的下游依赖 geom.getSRID() 判断坐标系,要自己设。

23.2.5 GeoTools 读文件的几何走向

Shapefile ──DataStore.getFeatureSource()──> SimpleFeatureCollection
     │                                            │
     │  (GeoTools 内部用 JTS 几何)                  │ 遍历 features
     ▼                                            ▼
  Geometry(读时可能是原始精度,也可能被 curve 化)  feature.getDefaultGeometry() → JTS Geometry
     │
     ├─ 需要投影:JTS.transform(geom, MathTransform)
     └─ 需要序列化:WKBWriter(二进制)/ GeoJsonWriter(Web)

要点:Shapefile 读取默认不做投影变换,返回的几何坐标还是原始坐标系;CRS 记录在 FeatureType 里。要统一坐标,读出来后自己 transform。


23.3 GeoServer 中的 JTS

23.3.1 GeoServer 的几何处理链

GeoServer 构建在 GeoTools 之上,因此它处理几何时用的就是 JTS。请求链路大致是:

WMS/WFS 请求 ──> GeoServer 渲染/输出引擎 ──> GeoTools DataStore ──> 数据源
                        │
                        ├─ 过滤器(Filter):CQL 的 INTERSECTS/CONTAINS 最终编译为 JTS 谓词
                        ├─ 重投影:JTS.transform
                        └─ 输出:GeoJSON/GML/KML(几何由 JTS 写出)

这意味着你在 GeoServer 里看到的“几何问题”,本质上是 JTS 行为:

  • WFS 输出的 GeoJSON 坐标精度由 GeoServer 的 GEOSERVER_JSON 输出格式参数控制,不是 JTS 的 GeoJsonWriter 默认行为。
  • SLD 里的 buffer 单位是坐标单位,不是米。要按米缓冲,必须先投影到米制 CRS 再写 SLD。
  • WMS 渲染的几何简化用的是 GeoTools 的简化器(内部仍是 JTS 的 DouglasPeuckerSimplifier)。

23.3.2 常见配置层陷阱

现象 根因 处置
WMS 出图边缘断裂 数据跨带、CRS 定义不一致 统一数据与图层 CRS,避免运行时跨带拼接
WFS 返回几何被“截断” 输出格式的 maxFeatures 或坐标精度限制 检查输出格式参数
CQL INTERSECTS 报 TopologyException 数据里有非法几何 入库前 ST_MakeValid 或 JTS GeometryFixer
缓冲区 SLD 尺寸忽大忽小 单位随 CRS 变 固定米制 CRS 或用 uom 明确指定

GeoServer 本身不提供“几何修复”开关,非法几何要在入库或 ETL 阶段解决。这一点和 PostGIS 不同,PostGIS 至少有 ST_MakeValid 可以在查询时兜底。


23.4 PostGIS / GEOS:ST_* 与 JTS 方法对照

23.4.1 为什么不能简单按名字对应

PostGIS 的空间能力由 GEOS 提供,而 GEOS 是 JTS 的 C++ 移植——算法思想同源,实现独立。这带来两个后果:

  1. 语义基本一致:相交、缓冲、有效性的判定逻辑是同一套算法演化来的,绝大多数结果相同。
  2. 边界情况可能不同:数值鲁棒性处理、精度模型默认值、异常类型都与 JTS 有差异。尤其是 GEOS 版本与 JTS 版本不同步时。

所以对照表的作用是“知道该调哪个函数”,不是“保证结果逐位相同”。关键业务要双端验证。

23.4.2 核心方法对照表

JTS(Java) PostGIS SQL 语义说明 注意
geom.intersection(other) ST_Intersection(a, b) 交集 两边都可能有拓扑异常
geom.union(other) ST_Union(a, b) 并集 双参数版是二元并
geom.union() ST_Union(geom) 一元并(聚合) PG 里是聚合函数
geom.difference(other) ST_Difference(a, b) 差集 注意参数顺序
geom.symDifference(other) ST_SymDifference(a, b) 对称差 —
geom.buffer(d) ST_Buffer(geom, d) 缓冲 单位=坐标单位,不是米
geom.buffer(d, qs) ST_Buffer(geom, d, 'quad_segs=n') 带象限段数 参数写法不同
OffsetCurve.getCurve(g, d) ST_OffsetCurve(geom, d) 偏移曲线 单侧线,不是面
geom.convexHull() ST_ConvexHull(geom) 凸包 —
geom.getCentroid() ST_Centroid(geom) 质心 对多点集合是加权平均
geom.getInteriorPoint() ST_PointOnSurface(geom) 面内点 保证落在面内
geom.getEnvelope() ST_Envelope(geom) 包络矩形 —
geom.getBoundary() ST_Boundary(geom) 边界 —
geom.isValid() ST_IsValid(geom) 有效性 判定细节可能有差异
IsValidOp.getValidationError() ST_IsValidReason(geom) 无效原因 返回文本
GeometryFixer.fix(geom) ST_MakeValid(geom) 修复 都尽量保面积,但策略不同
geom.getArea() ST_Area(geom) 面积 经纬度下是平方度
geom.getLength() ST_Length(geom) 长度 同上
geom.distance(other) ST_Distance(a, b) 距离 同上
geom.intersects(other) ST_Intersects(a, b) 相交 PG 会自动用索引
geom.contains(other) ST_Contains(a, b) 包含 —
geom.within(other) ST_Within(a, b) 被包含 与 Contains 互为反向
geom.covers(other) ST_Covers(a, b) 覆盖 边界也计入
geom.touches(other) ST_Touches(a, b) 相接 只共享边界
geom.relate(other, "T*F**F***") ST_Relate(a, b, 'T*F**F***') DE-9IM 匹配 两边语法一致
geom.relate(other) ST_Relate(a, b) 返回 DE-9IM 矩阵 —
DouglasPeuckerSimplifier.simplify(g, t) ST_Simplify(geom, t) DP 简化 可能产生无效几何
TopologyPreservingSimplifier.simplify(g, t) ST_SimplifyPreserveTopology(geom, t) TP 简化 保拓扑
VWSimplifier.simplify(g, t) ST_SimplifyVW(geom, t) VW 简化 保形状
Densifier.densify(g, t) ST_Segmentize(geom, t) 加密 语义一致
LineMerger ST_LineMerge(geom) 线合并 PG 返回 MultiLineString
Polygonizer ST_Polygonize(geom) 线构面 —
geom.norm() ST_Normalize(geom) 归一化 环起点/顺序统一
geom.reverse() ST_Reverse(geom) 反序 —
geom.getNumPoints() ST_NPoints(geom) 顶点数 —
geom.getNumGeometries() ST_NumGeometries(geom) 元素个数 —
geom.getGeometryN(i) ST_GeometryN(geom, i) 取第 i 个 PG 下标从 1 开始
WKBWriter.write(geom) ST_AsBinary(geom) 转 WKB —
WKTWriter.write(geom) ST_AsText(geom) 转 WKT —
GeoJsonWriter.write(geom) ST_AsGeoJSON(geom) 转 GeoJSON 精度参数不同
WKTReader.read(s) ST_GeomFromText(s, srid) 文本转几何 PG 要显式 SRID
WKBReader.read(bytes) ST_GeomFromWKB(bytes, srid) 二进制转几何 同上

23.4.3 单位陷阱:最贵的一课

同一句“缓冲 100 米”,两边的写法与正确性完全不同:

-- PostGIS:如果 geom 是 EPSG:4326,这 100 的单位是“度”,结果是错的
SELECT ST_Buffer(geom, 100) FROM parcels;

-- 正确写法一:先投影到米制 CRS 再缓冲
SELECT ST_Transform(
         ST_Buffer(ST_Transform(geom, 3857), 100), 4326)
FROM parcels;

-- 正确写法二:用 geography 类型(按米计算)
SELECT ST_Buffer(geom::geography, 100) FROM parcels;

JTS 侧对应的正确写法:

// 错误:geom 的坐标是经纬度,100 没有意义
Geometry wrong = geom.buffer(100);

// 正确:先投影(借 GeoTools),缓冲后再投影回去
Geometry projected = JTS.transform(geom, toMercator);
Geometry buffered = projected.buffer(100);
Geometry result = JTS.transform(buffered, toWgs84);

记住一句话:JTS 和 PostGIS 的 buffer 都只认坐标单位。 唯一例外是 PostGIS 的 geography 类型,它会在内部按球面计算——但那是 PostGIS 的扩展,不是 GEOS/JTS 的能力。

23.4.4 从 JTS 生成 PostGIS 可用的 SQL

批量入库时,用 WKB 十六进制是最稳的方式:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKBWriter;

public class PostgisInsertHelper {

  /**
   * 生成可直接拼进 SQL 的 ST_GeomFromWKB 表达式。
   * 注意:生产环境务必用 PreparedStatement + setBytes,不要字符串拼接。
   */
  public static String toInsertLiteral(Geometry geom, int srid) {
    WKBWriter writer = new WKBWriter(2, true);   // 2 维,包含 SRID
    byte[] wkb = writer.write(geom);
    return "ST_GeomFromWKB('\\x" + WKBWriter.toHex(wkb) + "'::bytea, " + srid + ")";
  }
}

更推荐的做法是走 JDBC 参数:

String sql = "INSERT INTO parcels(geom) VALUES (ST_GeomFromWKB(?, 4326))";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
  ps.setBytes(1, new WKBWriter(2, true).write(geom));
  ps.executeUpdate();
}

用 includeSRID=true 时,WKB 头部会内嵌 SRID,PG 端的 ST_SRID 就能直接读到,不必再传第二个参数。


23.5 NetTopologySuite(.NET)

23.5.1 关系与命名映射

NetTopologySuite(NTS)是 JTS 的 .NET 移植,API 结构高度相似但命名遵循 C# 规范:

JTS(Java) NTS(C#) 差异
org.locationtech.jts.geom.Geometry NetTopologySuite.Geometries.Geometry 命名空间不同
GeometryFactory GeometryFactory(NTS.Geometries) 构造方式略有差异
WKTReader / WKTWriter WKTReader / WKTWriter(NTS.IO) 用法几乎一致
geom.intersection(other) geom.Intersection(other) 方法首字母大写
geom.isValid() geom.IsValid 属性而非方法
geom.getArea() geom.Area 属性
STRtree STRtree 一致
GeometryFixer GeometryFixer(1.x 后期引入) 版本相关
CoverageValidator NetTopologySuite.Coverage.CoverageValidator 有对应实现

23.5.2 跨语言数据交换

JVM 与 .NET 之间传几何,唯一推荐 WKB:

// Java 端:写 WKB
WKBWriter writer = new WKBWriter(2, true);
byte[] bytes = writer.write(geom);
String hex = WKBWriter.toHex(bytes);
// C# 端:读 WKB
var reader = new NetTopologySuite.IO.WKBReader();
var geom = reader.Read(NetTopologySuite.IO.WKBReader.HexToBytes(hex));
Console.WriteLine(geom.Area);

不要用 WKT 跨语言传输:双方对 NaN、极高精度小数、EMPTY 的文本表示可能不同,解析失败率明显高于 WKB。


23.6 JSTS(JavaScript)与前端

23.6.1 JSTS 的定位

JSTS 是 JTS 的 JavaScript 移植,主要用在浏览器与 Node.js 里做几何运算。它的 API 刻意模仿 JTS:

// 浏览器/Node 中:使用 JSTS 的 GeoJSONReader/Writer
const jsts = require('jsts');
const reader = new jsts.io.GeoJSONReader();
const writer = new jsts.io.GeoJSONWriter();

const geom = reader.read({ type: 'Polygon', coordinates: [[[0,0],[10,0],[10,10],[0,10],[0,0]]] });
const buffered = geom.buffer(1);
console.log(writer.write(buffered));

对应 JTS 的 GeoJsonReader/GeoJsonWriter 与 geom.buffer(1)。语义基本一致,但实现经过 JS 化,浮点行为与 JTS 有细微差异。

23.6.2 前后端分工建议

任务 放在前端(JSTS) 放在后端(JTS)
即时预览(拖拽后立刻看效果) ✅ ❌ 延迟高
小于 1 万顶点的简单运算 ✅ 可选
批量叠加、海量数据 ❌ 内存爆 ✅
结果需要入库/审计 ❌ ✅
精度敏感(地籍、管线) ❌ ✅

一句话:JSTS 用于交互,JTS 用于权威计算。同一份几何在前端算完只用于展示,最终结果以后端为准。


23.7 Shapely 与 GEOS(Python)

23.7.1 依赖关系

Shapely 是 Python 的几何库,底层用 GEOS。所以链路是:

Python 业务代码 ──> Shapely ──> GEOS(C++) ──> 与 JTS 同源的算法

Shapely 2.x 起把 GEOS 的实现暴露得更彻底,性能大幅提升,但仍然不是 JTS。

23.7.2 JTS 与 Shapely 对照

JTS Shapely 说明
WKTReader.read(s) shapely.wkt.loads(s) —
WKTWriter.write(g) shapely.wkt.dumps(g) —
geom.intersection(o) a.intersection(b) —
geom.union(o) a.union(b) —
geom.buffer(d) g.buffer(d) 单位同样问题
geom.isValid() g.is_valid 属性
geom.is_empty() g.is_empty 属性
GeometryFixer.fix(g) shapely.make_valid(g) Shapely 2.0+
geom.getArea() g.area 属性
STRtree shapely.STRtree Shapely 2.0+ 直接提供
PreparedGeometry shapely.prepare(g) 2.0+
relate(a,b) a.relate(b) 返回 DE-9IM 字符串

跨语言交换仍用 WKB 最稳:

# Python 端读 JTS 生成的 WKB
from shapely import wkb
geom = wkb.loads(bytes.fromhex(hex_string))
print(geom.area, geom.is_valid)

23.7.3 一个常见的混合架构

Java 服务(JTS)──WKB──> Python 分析脚本(Shapely/GEOS)
       ↑                        │
       └──────WKB 结果回写───────┘

这种架构的关键约束是版本对齐:Shapely 的 GEOS 版本必须与 JTS 主版本大致同期,否则某些边界几何的结果会不同。用 WKB 交换能规避格式问题,但规避不了算法差异。


23.8 Hibernate Spatial 与 JPA

23.8.1 定位

Hibernate Spatial 把 JTS 几何映射到数据库的空间类型。它自己不实现几何算法,而是桥接:

JPA Entity 字段(Geometry)
        │  Hibernate Spatial 的 GeometryType / 方言
        ▼
数据库空间列(geometry / SDO_GEOMETRY / 自定义二进制)

23.8.2 实体映射

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

import org.locationtech.jts.geom.Geometry;

@Entity
public class Parcel {

  @Id
  private Long id;

  @Column(columnDefinition = "geometry(Polygon, 4326)")
  private Geometry geom;

  private String code;

  // getters / setters 省略
}

注意 columnDefinition 是方言相关的:PostgreSQL/PostGIS 写成 geometry(Polygon,4326),Oracle 写成 SDO_GEOMETRY,MySQL 写成 GEOMETRY。写死会让项目无法换库。

23.8.3 三个容易踩的坑

坑 表现 处置
依赖版本错配 NoSuchMethodError: Geometry.getSRID 用 Hibernate 官方 BOM 锁定 hibernate-spatial 与 hibernate-core
坐标系丢失 查询出来 SRID=0 写入前 geom.setSRID(4326),或由方言配置默认 SRID
延迟加载导致几何被代理包装 ClassCastException 用 Hibernate.unproxy() 或关闭该字段的懒加载

23.9 Elasticsearch 与 OpenSearch

23.9.1 关键认知:ES 不用 JTS

Elasticsearch 的空间检索基于 Lucene 的 spatial 模块,它有自己的几何表示(org.apache.lucene.geo.*),与 JTS 无关。你能在 ES 里用 GeoJSON 或 WKT 传几何,是因为客户端/服务端做了格式转换,不是因为它内部跑 JTS。

这带来两个实际影响:

  1. 精度模型不同:ES 的 geo_shape 默认按经纬度处理,且默认是“平面近似”;shape 类型才支持任意坐标系的平面几何。
  2. 支持的操作有限:ES 主要做查询(intersects/within/disjoint),不做叠加运算。想在 ES 里求两个面的交,做不到,必须取出来用 JTS 算。

23.9.2 字段类型与查询

字段类型 用途 坐标系 JTS 对应
geo_point 单点 经纬度 Point
geo_shape 任意地理几何 经纬度(WGS84) Geometry(需转 GeoJSON)
shape 平面几何 任意笛卡尔坐标 Geometry(需转 GeoJSON)
point 平面点 笛卡尔 Point

23.9.3 用 JTS 生成 ES 可索引的 GeoJSON

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.geojson.GeoJsonWriter;

public class EsGeoJsonHelper {

  /**
   * 把 JTS 几何转成 ES geo_shape 可直接接受的 JSON。
   * ES 的 geo_shape 要求坐标是 [lon, lat] 顺序,GeoJSON 标准也是这个顺序,
   * 所以这里不需要翻转。
   */
  public static String toGeoShapeJson(Geometry geom) {
    GeoJsonWriter writer = new GeoJsonWriter(6);   // 保留 6 位小数,约 0.1 米精度
    writer.setEncodeCRS(false);                     // ES 不接受 crs 字段
    return writer.write(geom);
  }
}

两个细节:GeoJsonWriter(int decimals) 控制小数位,地籍级精度建议 7 位;setEncodeCRS(false) 很重要,因为部分老版本 ES 会拒绝带 crs 成员的 GeoJSON。


23.9.4 常用 geo_shape 查询的 DSL

索引里的几何由 JTS 生成,查询端则是 ES 自己的 DSL。几个最常用的写法(可直接贴进 Kibana Dev Tools 验证):

// 1. 找出与给定多边形相交的文档(relation 默认 intersects)
POST /parcels/_search
{
  "query": {
    "geo_shape": {
      "geom": {
        "shape": {
          "type": "Polygon",
          "coordinates": [[[116.39, 39.90], [116.41, 39.90],
                           [116.41, 39.92], [116.39, 39.92],
                           [116.39, 39.90]]]
        },
        "relation": "intersects"
      }
    }
  }
}

// 2. 完全包含(文档几何被查询几何包含)
//    把 relation 改成 "within";反过来“查询几何被文档包含”用 "contains"

// 3. 只有距离条件时用 geo_distance(点数据、geo_point 字段)
GET /shops/_search
{
  "query": {
    "geo_distance": {
      "distance": "3km",
      "location": { "lat": 39.909, "lon": 116.397 }
    }
  }
}

relation 的四个取值对应关系很直接:

relation 语义 JTS 对应
intersects 有公共点(默认) intersects
within 文档几何在查询几何内部 within
contains 文档几何包含查询几何 contains
disjoint 不相交 disjoint

注意 ES 的 within/contains 是查询几何与文档几何的关系,方向容易搞反;建议先用一条已知数据验证方向,再写进业务代码。

23.10 Apache Sedona 与 Spark

23.10.1 定位

Apache Sedona(原 GeoSpark)在 Spark 之上做分布式空间计算,内部用 JTS 做单机几何运算,用空间索引与分区做分布式加速。所以它和 JTS 的关系是“包裹”而不是“替代”。

Sedona API 内部实现 对应 JTS
ST_Intersects(a, b) JTS intersects geom.intersects
ST_Contains JTS contains geom.contains
ST_Buffer JTS buffer geom.buffer
ST_Intersection JTS intersection geom.intersection
ST_Union JTS union geom.union
ST_IsValid JTS isValid geom.isValid
ST_MakeValid JTS GeometryFixer GeometryFixer.fix
ST_Transform 依赖 Proj4J GeoTools JTS.transform(JTS 本身无此能力)

注意最后一行:Sedona 的 ST_Transform 不是 JTS 的能力,它内部用 Proj4J。这说明“JTS 不含 CRS”这条约束在分布式栈里同样成立。

23.10.2 分布式场景下的 JTS 使用原则

原则 原因
每行几何尽量小(< 1 万顶点) JTS 单次运算内存与耗时随顶点数增长
先分区再算 空间连接必须靠空间分区避免 O(n²)
避免在 UDF 里 new 大量临时对象 JVM GC 压力大,Sedona 建议复用工厂
非法几何先 ST_MakeValid 分布式环境下一次异常会让整个 task 重试

23.10.3 一段可运行的 Sedona 作业骨架

Sedona 的 SQL 层把 JTS 调用包装成了 ST_* 函数,写法和 PostGIS 非常接近:

// Spark SQL:把 WKT 列转成几何,做空间连接,再输出 WKT
Dataset<Row> parcels = spark.read().parquet("parcels.parquet");   // 含 wkt 列
Dataset<Row> pois    = spark.read().parquet("pois.parquet");      // 含 wkt 列

parcels.createOrReplaceTempView("parcels");
pois.createOrReplaceTempView("pois");

Dataset<Row> joined = spark.sql(
    "SELECT p.id AS parcel_id, o.id AS poi_id, o.name "
  + "FROM parcels p JOIN pois o "
  + "ON ST_Intersects(ST_GeomFromWKT(p.wkt), ST_GeomFromWKT(o.wkt))");

joined.show(10);

三个实践要点:

  1. 空间连接必须靠分区:Sedona 用 ST_Contains 之类谓词做 join 时,会先按网格分区,避免 O(n²)。数据倾斜(某个网格要素过多)时性能会骤降,需要用 ST_Split/ST_SubDivide 预先切分超大几何。
  2. WKT 只是入口:Spark 内部用二进制几何,WKT 解析(ST_GeomFromWKT)是 CPU 热点,能落 Parquet/GeoParquet 就别反复解析 WKT。
  3. 非法几何会拖垮整个 task:分布式环境下一次异常会导致 task 重试,务必先 ST_MakeValid(内部就是 JTS 的 GeometryFixer)。

23.11 Spring Boot 中的 JTS

23.11.1 依赖与自动配置

最小依赖:

<dependency>
  <groupId>org.locationtech.jts</groupId>
  <artifactId>jts-core</artifactId>
  <version>1.20.0</version>
</dependency>

<!-- 需要 GeoJSON -->
<dependency>
  <groupId>org.locationtech.jts.io</groupId>
  <artifactId>jts-io-common</artifactId>
  <version>1.20.0</version>
</dependency>

<!-- 需要 Hibernate 空间映射 -->
<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-spatial</artifactId>
  <version>${hibernate.version}</version>
</dependency>

Spring Boot 本身没有 JTS 自动配置。GeometryFactory、WKTReader、WKBWriter 这些都要自己声明成 Bean,或者用静态工具类。

23.11.2 线程安全与 Bean 设计

类 线程安全 是否可复用 建议
GeometryFactory 是(不可变配置) 可 单例 Bean
WKTReader 否(内部有状态) 不可 每次 new 或 ThreadLocal
WKTWriter 否 不可 每次 new
WKBReader / WKBWriter 否 不可 每次 new
GeoJsonReader / GeoJsonWriter 否 不可 每次 new
PreparedGeometry 是 可 按几何缓存
STRtree 构建后只读则安全 可 构建一次,只读共享

这张表是很多“偶发乱码/解析失败”问题的根源:把 WKTReader 声明成单例 Bean,在并发下内部缓冲区被串改,解析结果随机出错。

import org.locationtech.jts.geom.GeometryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JtsConfig {

  /** 工厂无状态,可以安全共享。 */
  @Bean
  public GeometryFactory geometryFactory() {
    return new GeometryFactory();
  }

  // 反例:不要这样写!
  // @Bean
  // public WKTReader wktReader() { return new WKTReader(); }
}

23.11.3 一个典型的分层用法

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.springframework.stereotype.Service;

@Service
public class ParcelService {

  private final GeometryFactory geometryFactory;

  public ParcelService(GeometryFactory geometryFactory) {
    this.geometryFactory = geometryFactory;
  }

  /**
   * 解析 WKT 并做有效性修复。
   * WKTReader 每次新建,避免并发问题。
   */
  public Geometry parseAndFix(String wkt) throws Exception {
    WKTReader reader = new WKTReader(geometryFactory);
    Geometry geom = reader.read(wkt);
    return geom.isValid() ? geom : org.locationtech.jts.geom.util.GeometryFixer.fix(geom);
  }
}

23.12 版本兼容矩阵

23.12.1 JTS 自身的兼容承诺

变更类型 兼容性 说明
新增类/方法 二进制兼容 放心升级
修改方法签名 不兼容 极少发生,走弃用流程
删除弃用 API 不兼容 大版本才可能
内部实现变化(包私有) 兼容 但行为可能微调
默认行为切换 兼容但语义变化 如 1.20 的 jts.relate=ng

JTS 用 ABI Laboratory 跟踪二进制兼容性,但不保证跨大版本的行为完全一致。典型例子:1.20 引入 RelateNG 后,jts.relate=ng 会让部分关系谓词结果更精确(也更慢/更快),这类差异不会体现在签名上。

23.12.2 生态版本组合建议

栈 建议组合 风险点
GeoTools 29+ JTS 1.20.x GeoTools BOM 锁死 JTS 版本,不要手动覆盖
GeoServer 2.25+ 随 GeoTools 不要单独升级 JTS
Hibernate 6.4+ JTS 1.19.x / 1.20.x + hibernate-spatial hibernate-spatial 与 core 必须同版本
Sedona 1.6+ JTS 1.19.x / 1.20.x Spark 版本与 Sedona 绑定
Spring Boot 3.x 引入 JTS 1.20.x 注意 Jakarta 命名空间
ES 8.x 无 JTS 依赖 只需 GeoJSON 交换

23.12.3 用 Maven 检查冲突

# 看完整的依赖树,找重复的 JTS 版本
mvn dependency:tree -Dincludes=org.locationtech.jts

# 看某个依赖为什么被引入
mvn dependency:tree -Dincludes=org.locationtech.jts:jts-core -Dverbose

# 排除传递依赖里的旧版本
<dependency>
  <groupId>com.example</groupId>
  <artifactId>some-geo-lib</artifactId>
  <version>1.0</version>
  <exclusions>
    <exclusion>
      <groupId>org.locationtech.jts</groupId>
      <artifactId>jts-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>

统一版本的正确姿势是在父 POM 的 <dependencyManagement> 里锁一次,而不是在每个依赖上排除。


23.13 跨栈迁移注意事项

23.13.1 迁移检查清单

从 PostGIS 迁到 JTS,或从 JTS 迁到分布式引擎,按这份清单过一遍:

┌──────────────────────────────────────────────────────────────┐
│ 跨栈迁移检查清单                                              │
├──────────────────────────────────────────────────────────────┤
│ □ 1. 坐标系:目标栈是否处理 CRS?单位是米还是度?              │
│ □ 2. 有效性定义:目标栈的 isValid 与源栈是否一致?             │
│ □ 3. 精度模型:是否需要在边界加固定精度(scale factor)?      │
│ □ 4. 空几何语义:EMPTY 与 NULL 的处理是否一致?               │
│ □ 5. 环方向:是否要求外环顺时针/逆时针?                      │
│ □ 6. Z/M 值:目标栈是否丢弃?是否影响结果?                   │
│ □ 7. 索引:目标栈是否需要显式建空间索引?                     │
│ □ 8. 异常语义:拓扑异常是抛错还是返回 NULL?                  │
│ □ 9. 批量大小:单次传输的几何总量是否超限?                   │
│ □ 10. 并发:Reader/Writer 是否被共享?                        │
└──────────────────────────────────────────────────────────────┘

23.13.2 五个真实的“迁移事故”

事故一:环方向导致面积符号反转。 JTS 的 Polygon 不强制环方向,但某些栈(如部分三维引擎、某些 OGC 服务)要求外环逆时针。迁移后面积变负或渲染成洞。

事故二:EMPTY 与 NULL。 PostGIS 里 ST_GeomFromText('POLYGON EMPTY') 是合法的空几何,JTS 也能表示;但某些 ORM 会把空几何映射成 null,于是 geom.isEmpty() 变成 NPE。

事故三:坐标顺序。 GeoJSON 是 [lon, lat],某些库内部是 (lat, lon)。EPSG:4326 的官方轴序其实是 (lat, lon),而 GIS 惯例是 (lon, lat)——GeoTools 的 CRS.decode(srid, true) 就是在处理这件事。

事故四:精度截断。 前端 GeoJSON 默认 6 位小数(约 0.1 米),地籍数据要求毫米级,来回一趟就丢了精度。内部一律用 WKB。

事故五:版本静默降级。 Maven 传递依赖把 jts-core 从 1.20 拉到 1.18,代码里用了 RelateNG 就在运行期 NoClassDefFoundError。用 dependency:tree 定期体检。

23.13.3 迁移的验证策略

  抽样几何集(含边界案例)
        │
        ├──> 源栈计算 ──> 结果 R1(WKT 规范化后比较)
        │
        └──> 目标栈计算 ──> 结果 R2
                 │
                 ▼
        比较策略:
          1. 精确比对(normalize 后 equalsExact)
          2. 容差比对(equalsExact(tol))
          3. 语义比对(面积/长度/顶点数在容差内)
          4. 拓扑比对(DE-9IM 矩阵一致)

实践建议:边界案例比随机样本更有价值。准备一批“共线、重合边、洞贴边、自相交、极小面积、极大坐标”的用例,比跑一万条普通数据更能暴露差异。


23.14 常见问题与避坑

23.14.1 GeoTools 相关

Q:JTS.transform 报 IllegalArgumentException: Expected 2 dimensions。 A:源几何与目标 CRS 的轴数不匹配。常见于 Z 值几何转二维 CRS,或 CRS.decode 时轴序参数写错。先确认 src.getCoordinateSystem().getDimension()。

Q:坐标转换后整体偏移几百米。 A:几乎都是轴序问题。CRS.decode("EPSG:4326") 默认可能是 (lat, lon),加 true 参数强制经度在前。

Q:GeoTools 读出来的几何 isValid() 是 false。 A:源数据本身非法。GeoTools 不做自动修复,读出来后自己 GeometryFixer.fix。

23.14.2 PostGIS 相关

Q:JTS 算的 ST_Area 和 PostGIS 不一样。 A:先确认两者坐标系是否相同。经纬度下 PostGIS 的 ST_Area 也是平方度;若用了 geography 类型则按平方米,两者不该直接比。

Q:ST_Buffer 在 PG 里比 JTS 快很多。 A:PG 有索引与查询计划优化,且 GEOS 的缓冲实现经过 C++ 优化。单次运算差距不大,差距来自批量与索引。

Q:ST_Union 超时。 A:用 ST_Union(ARRAY[...]) 或先 ST_Collect 再 union,避免逐行聚合反复扫描。

23.14.3 其他栈

Q:ES 里能算两个面的交集吗。 A:不能。ES 只做查询匹配,叠加运算要取出几何用 JTS 算。

Q:Shapely 与 JTS 结果不一致。 A:GEOS 与 JTS 版本不同步。用 WKB 交换能排除格式问题,但算法差异仍在;关键业务做双端对照。

Q:Hibernate 报 UnsupportedOperationException: Geometry is not supported。 A:没加 hibernate-spatial 依赖,或方言没配。PostgreSQL 用 PostgisPGDialect(具体类名随 Hibernate 版本变化,以官方文档为准)。


23.15 本章小结

  • JTS 只活在 JVM 上;GEOS、NTS、JSTS 是同源思想的独立移植,ES 的 Lucene spatial 与 JTS 无关。
  • JTS 不含 CRS,所有距离/面积/缓冲的单位都是坐标单位;投影必须交给 GeoTools 或 Proj4J。
  • GeoTools 与 JTS 版本强绑定,用其 BOM 锁版本;几何工厂用 JTSFactoryFinder.getGeometryFactory(),不要自己 new。
  • CRS.decode("EPSG:4326", true) 的 longitudeFirst 参数是最高频的坐标错位来源。
  • PostGIS 用 GEOS,与 JTS 语义基本一致但边界可能不同;ST_* 与 JTS 方法可按下表对照,单位语义必须显式处理。
  • 跨栈传几何优先 WKB(无损、体积小),WKT 用于调试,GeoJSON 用于前端展示。
  • NTS/JSTS/Shapely 的 API 与 JTS 高度相似,但方法名大小写、属性 vs 方法、版本支持度各不相同。
  • Hibernate Spatial 只做映射,不做算法;columnDefinition 是方言相关的,写死会锁死数据库。
  • Elasticsearch/OpenSearch 不做叠加运算,只做查询匹配,字段类型分 geo_shape/shape。
  • Sedona 在 Spark 上包裹 JTS,ST_Transform 靠 Proj4J 而非 JTS。
  • Spring Boot 里 GeometryFactory 可单例,WKTReader/WKBReader/GeoJsonWriter 绝不能单例。
  • 版本兼容性用 mvn dependency:tree -Dincludes=org.locationtech.jts 定期检查,在父 POM 的 dependencyManagement 里统一锁定。

相关资源