第06章 - WKT 与 WKB 序列化

几何对象在内存里是一堆 Coordinate 与结构指针,一旦要出进程——写数据库、发 HTTP、落文件、传消息队列——就必须序列化。JTS 提供两种“世界语”:WKT(Well-Known Text)与 WKB(Well-Known Binary)。前者人可读、能直接贴进 SQL,后者机器友好、体积小、精度无损。几乎所有与空间数据打交道的系统都至少支持其中一种,这也是为什么这两个类(WKTReader/WKTWriter、WKBReader/WKBWriter)是 JTS 里被调用次数最多的 IO 类。

但“会用”和“用对”之间隔着不少细节。WKT 的 Z/M 标记怎么写、POINT EMPTY 与 POINT (NaN NaN) 有什么区别、WKTWriter 的精度模型怎么影响输出、WKB 的类型整数里第 25 位是什么、SRID 放在哪、大端小端怎么选、为什么 M 值往返一圈就丢了——这些问题在 Javadoc 里分散着,在事故现场却往往以“数据对不上”的形式集中爆发。

本章按“WKT 读写 → 维度与精度 → WKB 字节布局 → SRID 与 EWKB → 性能与选型”的顺序讲透这条链路。所有结论都对照 JTS 1.21 源码核对过,示例输出为实跑结果。读完后你应该能:手写一个符合规范的 WKT 解析器验证用例、看懂一段十六进制 WKB 的每个字节、在“文本还是二进制”的选择上给出有依据的答案,并避开 M 值丢失、NaN 传播、空几何误判这几类高频坑。

前置知识:第 4 章(几何对象模型)、第 5 章(几何创建与编辑)。如果对 CoordinateSequence 与 GeometryFactory 还不熟,建议先回看。


6.1 两种序列化格式的定位

6.1.1 一张对比表

维度 WKT WKB
规范来源 OGC Simple Features(ISO 13249) OGC Simple Features(ISO 13249)
可读性 人可读,可直接贴 SQL 二进制,需转十六进制才能看
体积 大(每个坐标约 20~25 字节文本) 小(每个坐标 16 字节 + 头部)
精度 文本格式化可能丢有效位 IEEE 754 双精度,无损
空几何 POINT EMPTY 等 有专门编码(坐标数为 0 或 NaN)
Z/M 支持 通过 Z/M/ZM 标记 通过类型整数的维度位
跨语言支持 极广 极广
典型用途 调试、SQL 字面量、测试用例、日志 服务间传输、入库、缓存
JTS 入口 WKTReader / WKTWriter WKBReader / WKBWriter

一句话选型:调试与人工介入用 WKT,机器之间传输用 WKB。如果两者都要(比如日志里记 WKT、消息里传 WKB),也是常规做法,成本很低。

6.1.2 在 JTS 里的位置

org.locationtech.jts.io
├── WKTReader          ← 文本 → Geometry
├── WKTWriter          ← Geometry → 文本
├── WKTFileReader      ← 文件(多个 WKT)→ Geometry 列表
├── WKTConstants       ← POINT / LINESTRING / Z / M / EMPTY 等常量
├── WKBReader          ← 字节 → Geometry
├── WKBWriter          ← Geometry → 字节
├── WKBHexFileReader   ← 十六进制文件 → Geometry 列表
├── WKBConstants       ← 类型码与字节序常量
├── Ordinate           ← 枚举:X / Y / Z / M
├── OrdinateFormat     ← 坐标数值格式化(有效位控制)
├── CheckOrdinatesFilter ← 检查几何里实际存在的维度
├── ParseException     ← 统一的解析异常
├── ByteOrderValues    ← 大端/小端常量与读写
└── InStream / OutStream ← 字节流抽象

全部在 jts-core 里,不需要额外依赖。这是 JTS 的一个贴心设计:最常用的两种格式零依赖可用,只有 GeoJSON 与 TWKB 才需要 jts-io-common。

6.1.3 两个类的基本用法

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.io.WKBWriter;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;

public class HelloWktWkb {

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

    // 文本 → 几何
    Geometry geom = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");

    // 几何 → 文本
    WKTWriter wktWriter = new WKTWriter();
    System.out.println(wktWriter.write(geom));

    // 几何 → 字节
    WKBWriter wkbWriter = new WKBWriter();
    byte[] bytes = wkbWriter.write(geom);
    System.out.println("WKB 长度 = " + bytes.length);
    System.out.println("十六进制 = " + WKBWriter.toHex(bytes));

    // 字节 → 几何
    WKBReader wkbReader = new WKBReader(gf);
    Geometry back = wkbReader.read(bytes);
    System.out.println("往返一致 = " + back.equalsExact(geom));
  }
}

输出(本机 jts-core 1.20.0 实跑):

POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
WKB 长度 = 93
十六进制 = 000000000300000001000000050000000000000000000000000000000040240000000000000000000000000000402400000000000040240000000000000000000000000000402400000000000000000000000000000000000000000000
往返一致 = true

先别急着看那串十六进制,6.8 节会逐段拆开它。这里只需要建立一个直觉:一个 5 个点的矩形,WKT 是 40 来个字符,WKB 是 93 字节。点越多,差距越大。


6.2 WKTReader 详解

6.2.1 公开 API

public class WKTReader {
  public WKTReader();
  public WKTReader(GeometryFactory geometryFactory);

  public void setIsOldJtsCoordinateSyntaxAllowed(boolean value);
  public void setIsOldJtsMultiPointSyntaxAllowed(boolean value);
  public void setFixStructure(boolean isFixStructure);

  public Geometry read(String wellKnownText) throws ParseException;
  public Geometry read(Reader reader) throws ParseException;
}

API 面很窄,只有两个构造器、三个开关、两个 read。窄是有意为之:WKT 语法由规范固定,读取端不该有太多自由度。

6.2.2 GeometryFactory 的作用

WKTReader 可以接受一个 GeometryFactory,这决定了三件事:

  1. 产出几何的工厂归属。后续调用 geom.getFactory() 拿到的就是它。
  2. 坐标序列的实现。默认 GeometryFactory 用 CoordinateArraySequenceFactory(源码 getDefaultCoordinateSequenceFactory() 返回它),你也可以显式传 PackedCoordinateSequenceFactory 得到紧凑的堆外实现。注意 CoordinateArraySequence 的默认维度是 3(源码 private int dimension = 3),所以即便几何只有 XY,序列的 getDimension() 也可能返回 3。
  3. 精度模型。工厂里的 PrecisionModel 会带到几何上,影响后续运算。
import org.locationtech.jts.geom.CoordinateArraySequenceFactory;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;

public class ReaderFactoryDemo {

  public static void main(String[] args) throws Exception {
    // 固定精度:1e6 表示保留 6 位小数
    GeometryFactory fixed = new GeometryFactory(
        new PrecisionModel(1e6),
        4326,
        CoordinateArraySequenceFactory.instance());

    WKTReader reader = new WKTReader(fixed);
    System.out.println("SRID = " + reader.read("POINT (1 2)").getSRID());
    System.out.println("工厂精度 = " + reader.read("POINT (1 2)").getPrecisionModel());
  }
}

输出:

SRID = 4326
工厂精度 = PrecisionModel[type=FIXED, scale=1000000.0, ...]

注意 read 出来的几何继承工厂的 SRID。这一点常被误解:WKT 文本里没有 SRID 信息(传统的 SRID=4326;POINT(...) 是 PostGIS 扩展,JTS 不认),所以 SRID 只能靠工厂带。如果工厂不带 SRID(默认 0),读出来的几何 getSRID() 就是 0。

6.2.3 三个开关

setIsOldJtsCoordinateSyntaxAllowed(boolean)

JTS 早期允许坐标之间用逗号分隔,比如 POINT (1, 2)。标准 WKT 要求空格分隔(POINT (1 2))。默认这个开关是 false,即不兼容老语法;设为 true 后两种都认。

WKTReader reader = new WKTReader();
// 默认:POINT (1, 2) 会抛 ParseException
try {
  reader.read("POINT (1, 2)");
} catch (ParseException e) {
  System.out.println("默认拒绝旧语法: " + e.getMessage());
}

// 打开兼容开关
reader.setIsOldJtsCoordinateSyntaxAllowed(true);
System.out.println(reader.read("POINT (1, 2)"));

setIsOldJtsMultiPointSyntaxAllowed(boolean)

老版本 JTS 允许 MULTIPOINT (1 1, 2 2) 这种“不带内层括号”的写法。标准 WKT 要求 MULTIPOINT ((1 1), (2 2))。开关打开后两种都接受。这个兼容点很实用——很多历史数据就是老写法。

setFixStructure(boolean)

这个开关控制“结构修复”:当输入里出现环不闭合、嵌套层级不对等结构问题时,是否尝试修正而不是直接报错。默认 false。生产环境不建议打开,因为“静默修复”会让上游数据问题被掩盖;宁可解析失败让 ETL 报出来。

6.2.4 支持的 WKT 类型

WKTReader 认全了 OGC 的七种几何加一个 LINEARRING:

WKT 关键字 产出类型 备注
POINT Point POINT EMPTY 合法
LINESTRING LineString 至少 2 个点,否则 ParseException
LINEARRING LinearRing JTS 扩展,非标准 WKT;环必须闭合
POLYGON Polygon 第一个环是外环
MULTIPOINT MultiPoint 两种括号写法
MULTILINESTRING MultiLineString —
MULTIPOLYGON MultiPolygon 嵌套三层括号
GEOMETRYCOLLECTION GeometryCollection 可混合类型

LINEARRING 是 JTS 自己的扩展,标准 WKT 里没有。用它读进来的对象是 LinearRing,但写出去再读回来会变成 LINESTRING(除非你用 WKTWriter 的特殊配置),这是一个容易踩的不对称。

6.2.5 空几何的三种写法

WKT 里表达“空”有几种形式,语义不完全相同:

写法 解析结果 isEmpty() 说明
POINT EMPTY 空 Point true 标准写法;内部坐标为 NaN,getCoordinate() 返回 null
POINT (NaN NaN) 非空 Point,坐标是 NaN false 这是 1.20 的实际行为,见下方说明
LINESTRING EMPTY 空 LineString true 标准写法
POLYGON EMPTY 空 Polygon true 标准写法
GEOMETRYCOLLECTION EMPTY 空集合 true 标准写法

这里要纠正一个流传很广的说法:POINT (NaN NaN) 并不会被归一成空几何。在 jts-core 1.20.0 上实测:

WKTReader reader = new WKTReader();
Geometry nan = reader.read("POINT (NaN NaN)");
System.out.println(nan + " / empty=" + nan.isEmpty()
    + " / valid=" + nan.isValid() + " / pts=" + nan.getNumPoints());
POINT (NaN NaN) / empty=false / valid=false / pts=1

也就是说它是一个“有一个点、但点坐标是 NaN、因此无效”的几何,而不是空几何。WKB 里的空点确实用 NaN 坐标编码,但读 WKT 时两者不互相转换。业务代码判断“有没有坐标”应该用 isEmpty(),而不是 getCoordinate().x != NaN(后者在 POINT EMPTY 上会 NPE,因为 getCoordinate() 返回 null)。

WKTReader reader = new WKTReader();
System.out.println(reader.read("POINT EMPTY").isEmpty());       // true
System.out.println(reader.read("POINT EMPTY").getCoordinate()); // null
System.out.println(reader.read("POINT (1 2)").isEmpty());       // false
System.out.println(reader.read("LINESTRING EMPTY").isEmpty());  // true
System.out.println(reader.read("POLYGON EMPTY").isEmpty());     // true

版本提示:1.21 起 WKTReader 支持读 Inf 与 -Inf(版本历史里记为 “Allow WKTReader to read Inf and -Inf ordinates”);在 1.20.0 上读 POINT (Inf 1) 会抛 ParseException: Invalid number: Inf。如果你的代码依赖无穷值,必须明确锁定 JTS 版本。

6.2.6 ParseException

解析失败时抛的异常不止一种。先看一个把五类典型坏输入都跑一遍的例子:

import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;

public class ParseErrorDemo {

  public static void main(String[] args) {
    WKTReader reader = new WKTReader();
    String[] bad = {
        "POINT (1 2",
        "POLYGON ((0 0, 1 1, 2 2))",   // 环不闭合
        "LINESTRING (1 1)",             // 点数不足
        "CIRCLE (0 0, 1)",              // 不支持的类型
        "POINT (a b)"                   // 非数字
    };
    for (String wkt : bad) {
      try {
        reader.read(wkt);
        System.out.println("意外成功: " + wkt);
      } catch (ParseException e) {
        System.out.println("失败(语法): " + wkt + " -> " + e.getMessage());
      } catch (IllegalArgumentException e) {
        System.out.println("失败(结构): " + wkt + " -> " + e.getMessage());
      }
    }
  }
}

输出(本机 jts-core 1.20.0 实跑):

失败: POINT (1 2
   -> Expected word but found End-of-Stream (line 1)
失败: POLYGON ((0 0, 1 1, 2 2)) -> IllegalArgumentException: Points of LinearRing do not form a closed linestring
失败: LINESTRING (1 1) -> IllegalArgumentException: Invalid number of points in LineString (found 1 - must be 0 or >= 2)
失败: CIRCLE (0 0, 1) -> ParseException: Unknown type: CIRCLE
失败: POINT (a b) -> ParseException: Expected word but found 'a' (line 1)

这里暴露了一个很容易踩的细节:并非所有“坏输入”都抛 ParseException。

失败类型 异常类型 原因
语法错误(缺括号、非数字、未知类型) ParseException(受检) 解析器直接拒绝
结构非法(环不闭合、线点数不足) IllegalArgumentException(非受检) 解析成功,但 GeometryFactory 构造几何时校验失败

也就是说,catch (ParseException e) 抓不全脏数据。生产代码的正确姿势是:

try {
  Geometry g = reader.read(wkt);
  // ...
} catch (ParseException e) {
  // 语法错误:上游给的文本不符合 WKT
  log.warn("bad wkt syntax: {}", wkt, e);
} catch (IllegalArgumentException e) {
  // 结构错误:语法对但几何非法(环不闭合、点数不足等)
  log.warn("bad wkt structure: {}", wkt, e);
}

两种都归为“数据问题”而不是“程序 bug”,但要分别打不同的日志标签,方便 ETL 统计。


6.3 WKTWriter 详解

6.3.1 公开 API

public class WKTWriter {
  public static String toPoint(Coordinate p0);
  public static String toLineString(CoordinateSequence seq);
  public static String toLineString(Coordinate[] coord);
  public static String toLineString(Coordinate p0, Coordinate p1);
  public static String format(Coordinate p);

  public WKTWriter();
  public WKTWriter(int outputDimension);

  public void setFormatted(boolean isFormatted);
  public void setMaxCoordinatesPerLine(int coordsPerLine);
  public void setTab(int size);
  public void setOutputOrdinates(EnumSet<Ordinate> outputOrdinates);
  public EnumSet<Ordinate> getOutputOrdinates();
  public void setPrecisionModel(PrecisionModel precisionModel);

  public String write(Geometry geometry);
  public void write(Geometry geometry, Writer writer);
  public String writeFormatted(Geometry geometry);
  public void writeFormatted(Geometry geometry, Writer writer);
}

注意这里没有 setTrim()、没有 setOld3D()。网上一些老文章把 WKTWriter.setTrim 当成 JTS 的 API,那是别的库(或极老版本)的写法;当前版本控制“去重空格”靠的是输出格式本身,控制“三维标记”靠 setOutputOrdinates。以源码为准。

6.3.2 精度控制:setPrecisionModel

这是 WKTWriter 最有用也最容易用错的开关。它决定坐标数字输出多少位:

import java.math.BigDecimal;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTWriter;

public class PrecisionDemo {

  public static void main(String[] args) {
    GeometryFactory gf = new GeometryFactory();
    Coordinate c = new Coordinate(116.39742800001, 39.90923000002, 12.3456789);

    WKTWriter plain = new WKTWriter();
    System.out.println("默认         : " + plain.write(gf.createPoint(c)));

    WKTWriter six = new WKTWriter();
    six.setPrecisionModel(new PrecisionModel(1e6));
    System.out.println("1e6 (6位小数): " + six.write(gf.createPoint(c)));

    WKTWriter three = new WKTWriter();
    three.setPrecisionModel(new PrecisionModel(1e3));
    System.out.println("1e3 (3位小数): " + three.write(gf.createPoint(c)));
  }
}

输出(本机实跑):

默认         : POINT (116.39742800001 39.90923000002)
1e6 (6位小数): POINT (116.397428 39.90923)
1e3 (4位小数): POINT (116.3974 39.9092)

实现上,setPrecisionModel 把 PrecisionModel.getMaximumSignificantDigits() 传给 OrdinateFormat.create(...),后者用它设置 DecimalFormat 的 setMaximumFractionDigits(最大小数位数):

// WKTWriter 源码关键两行
public void setPrecisionModel(PrecisionModel precisionModel) {
  this.precisionModel = precisionModel;
  this.ordinateFormat = OrdinateFormat.create(precisionModel.getMaximumSignificantDigits());
}

// PrecisionModel:FIXED 模型下这个值 = 1 + ceil(log10(scale))
public int getMaximumSignificantDigits() {
  int maxSigDigits = 16;
  if (modelType == FLOATING)        maxSigDigits = 16;
  else if (modelType == FLOATING_SINGLE) maxSigDigits = 6;
  else if (modelType == FIXED)
    maxSigDigits = 1 + (int) Math.ceil(Math.log(getScale()) / Math.log(10));
  return maxSigDigits;
}

这解释了一个反直觉的细节:名字叫 “SignificantDigits”,实际作用是“最大小数位数”,而且 Fixed 模型下比 log10(scale) 还多 1。源码注释也承认这一点:“for scale factors which are powers of 10 the value returned is 1 greater than the true value”。

所以:

  • new PrecisionModel(1e6) → 7 位最大小数位 → 116.39742800001 输出为 116.397428(第 7 位是 0,被 DecimalFormat 去掉)。
  • new PrecisionModel(1e3) → 4 位最大小数位 → 输出 116.3974。
  • 默认不设 PrecisionModel 时用 OrdinateFormat.DEFAULT,最多 325 位小数,基本等于“原样输出双精度”。

要注意它不是“固定小数位”:116.397428 只有 6 位小数,因为第 7 位是 0 被省略了。想要严格固定位数,WKTWriter 做不到,得自己格式化坐标拼 WKT,或先 GeometryPrecisionReducer 规整坐标再写。

6.3.3 格式化输出

这里有个很容易踩的坑:setFormatted(true) 对 write(Geometry) 完全无效。看源码:

public String write(Geometry geometry) {
  Writer sw = new StringWriter();
  writeFormatted(geometry, false, sw);   // 写死 false!
  return sw.toString();
}

public void write(Geometry geometry, Writer writer) throws IOException {
  writeFormatted(geometry, isFormatted, writer);   // 这里才用开关
}

public String writeFormatted(Geometry geometry) {
  // ...
  writeFormatted(geometry, true, sw);    // 写死 true
  // ...
}

也就是说,三种写法的格式化行为是:

方法 是否格式化 受 setFormatted 影响
write(Geometry) 永远不 否
write(Geometry, Writer) 取决于 setFormatted 是
writeFormatted(Geometry) 永远格式化 否(强制 true)
writeFormatted(Geometry, Writer) 永远格式化 否(强制 true)

实测对比:

Geometry g = new WKTReader().read(
    "MULTIPOLYGON (((0 0, 10 0, 10 10, 0 10, 0 0)), ((20 0, 30 0, 30 10, 20 10, 20 0)))");

WKTWriter w = new WKTWriter();
w.setFormatted(true);
w.setMaxCoordinatesPerLine(2);
w.setTab(2);

System.out.println("== write(Geometry) ==");
System.out.println(w.write(g));

System.out.println("== writeFormatted ==");
System.out.println(w.writeFormatted(g));

输出(本机实跑):

== write(Geometry) ==
MULTIPOLYGON (((0 0, 10 0, 10 10, 0 10, 0 0)), ((20 0, 30 0, 30 10, 20 10, 20 0)))

== writeFormatted ==
MULTIPOLYGON (((0 0, 10 0, 
  10 10, 0 10, 
  0 0)), 
  ((20 0, 30 0, 
    30 10, 20 10, 
    20 0)))

三个参数的关系:setTab 控制缩进宽度,setMaxCoordinatesPerLine 控制每行最多几个坐标(<=0 表示不限),setFormatted 只对 write(Geometry, Writer) 生效。

实践建议:想要格式化输出,直接用 writeFormatted(...),不要指望 setFormatted(true) + write(...)。这个组合在无数博客里出现过,但它是错的。

6.3.4 静态便捷方法

不想 new 对象时可以用静态方法,它们适合“临时拼一个 WKT 看看”:

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.io.WKTWriter;

System.out.println(WKTWriter.toPoint(new Coordinate(1, 2)));
System.out.println(WKTWriter.toLineString(new Coordinate(0, 0), new Coordinate(1, 1)));
System.out.println(WKTWriter.format(new Coordinate(1.23456789, 2)));

输出:

POINT (1 2)
LINESTRING (0 0, 1 1)
1.23456789 2

注意 format(Coordinate) 返回的是 “x y” 字符串(没有类型前缀),内部用 OrdinateFormat.DEFAULT;它是给拼装用的,不是完整 WKT。


6.4 Z 与 M:三维与度量

6.4.1 四种坐标维度

JTS 的坐标支持四种维度组合,对应 Ordinate 枚举的四个值:

含义 写法 坐标类 WKT 标记
二维 XY Coordinate 无标记
三维(高程) XYZ Coordinate Z
带度量 XYM CoordinateXYM M
三维带度量 XYZM CoordinateXYZM ZM

Ordinate 是枚举,提供四个预置组合的静态工厂:

import java.util.EnumSet;
import org.locationtech.jts.io.Ordinate;

EnumSet<Ordinate> xy   = Ordinate.createXY();     // X, Y
EnumSet<Ordinate> xyz  = Ordinate.createXYZ();    // X, Y, Z
EnumSet<Ordinate> xym  = Ordinate.createXYM();    // X, Y, M
EnumSet<Ordinate> xyzm = Ordinate.createXYZM();   // X, Y, Z, M

// 也可以手工构造
EnumSet<Ordinate> custom = EnumSet.of(Ordinate.X, Ordinate.Y, Ordinate.Z);

注意这些静态方法返回的是 clone(源码注释写明 “a copy is returned as EnumSets are not immutable”),所以你改返回值不会污染内部常量。

6.4.2 WKT 的维度标记

标准写法是在类型关键字后加 Z 或 M:

POINT Z (1 2 3)
LINESTRING Z (0 0 0, 1 1 1)
POLYGON M ((0 0 5, 10 0 5, 10 10 5, 0 0 5))
POINT ZM (1 2 3 4)

WKTReader 会按标记决定产出几何的坐标维度。但有一个容易忽略的行为:不加标记时,JTS 会按数字个数推断维度。实测:

WKTReader r = new WKTReader(new GeometryFactory());
Geometry g = r.read("POINT (1 2 3)");          // 没写 Z,但给了 3 个数
System.out.println(((Point) g).getCoordinateSequence().getDimension());  // 3
System.out.println(new WKTWriter(3).write(g)); // POINT Z(1 2 3)

所以“不写 Z 就是 2D”这条直觉在 JTS 里不成立——它更宽容,会按坐标数推断。但这不代表可以省略标记:不同库对无标记三元组的解释并不一致(有的按 Z、有的报错、有的按 M),跨系统交换时务必显式写上 Z/M/ZM。

6.4.3 写出的维度由 outputDimension 与 outputOrdinates 共同决定

// 构造器直接给维度
WKTWriter w3 = new WKTWriter(3);      // 输出 3 维
WKTWriter w4 = new WKTWriter(4);      // 输出 4 维

// 或者先构造再设置
WKTWriter w = new WKTWriter();
w.setOutputOrdinates(Ordinate.createXYZ());

setOutputOrdinates 的语义有陷阱,源码里写得很清楚:

public void setOutputOrdinates(EnumSet<Ordinate> outputOrdinates) {
  this.outputOrdinates.remove(Ordinate.Z);
  this.outputOrdinates.remove(Ordinate.M);

  if (this.outputDimension == 3) {
    if (outputOrdinates.contains(Ordinate.Z))
      this.outputOrdinates.add(Ordinate.Z);
    else if (outputOrdinates.contains(Ordinate.M))   // 注意是 else if
      this.outputOrdinates.add(Ordinate.M);
  }
  if (this.outputDimension == 4) {
    if (outputOrdinates.contains(Ordinate.Z))
      this.outputOrdinates.add(Ordinate.Z);
    if (outputOrdinates.contains(Ordinate.M))
      this.outputOrdinates.add(Ordinate.M);
  }
}

三条推论:

  1. outputDimension == 2 时,怎么设都不会输出 Z/M。想输出维度必须先 new WKTWriter(3) 或 new WKTWriter(4)。
  2. outputDimension == 3 且同时传 XYZM 时,只会输出 Z(不输出 M)——因为 Z 分支命中了 else if 就不再走 M。
  3. outputDimension == 4 才是真正的 ZM 双开。

这是一个“看 Javadoc 看不出来、看源码才明白”的行为,实际项目中经常有人困惑“我明明设了 XYM 为什么输出没有 M”——答案是他用了默认构造器(维度 2)。

6.4.4 一个完整的维度实验

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateXYZM;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.Ordinate;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;

public class DimensionDemo {

  public static void main(String[] args) throws Exception {
    GeometryFactory gf = new GeometryFactory();
    Geometry p = gf.createPoint(new CoordinateXYZM(1, 2, 3, 4));

    System.out.println("原始坐标类 = " + p.getCoordinate().getClass().getSimpleName());
    System.out.println("维度       = " + ((org.locationtech.jts.geom.Point) p)
        .getCoordinateSequence().getDimension());

    WKTWriter w2 = new WKTWriter(2);
    System.out.println("WKTWriter(2)      : " + w2.write(p));

    WKTWriter w3 = new WKTWriter(3);
    System.out.println("WKTWriter(3)      : " + w3.write(p));

    WKTWriter w3m = new WKTWriter(3);
    w3m.setOutputOrdinates(Ordinate.createXYM());
    System.out.println("WKTWriter(3)+XYM  : " + w3m.write(p));

    WKTWriter w4 = new WKTWriter(4);
    System.out.println("WKTWriter(4)      : " + w4.write(p));

    // 读回来看看
    WKTReader reader = new WKTReader(gf);
    Geometry back = reader.read("POINT ZM (1 2 3 4)");
    System.out.println("读回 ZM          : " + new WKTWriter(4).write(back));
    System.out.println("读回坐标类        : " + back.getCoordinate().getClass().getSimpleName());
  }
}

输出(本机实跑):

原始坐标类 = CoordinateXYZM
维度       = 4
WKTWriter(2)      : POINT (1 2)
WKTWriter(3)      : POINT Z(1 2 3)
WKTWriter(3)+XYM  : POINT M(1 2 4)
WKTWriter(4)      : POINT ZM(1 2 3 4)
读回 ZM          : POINT ZM(1 2 3 4)
读回坐标类        : CoordinateXYZM

注意输出里 Z( 之间没有空格:WKTWriter 的 appendOrdinateText 是直接追加 Z / M / ZM 再写括号,所以标准形态是 POINT Z(1 2 3)。读取端两种都认(POINT Z (1 2 3) 与 POINT Z(1 2 3) 都能解析),但如果你用字符串比对做测试,一定要用 writer 实际产出的格式。

注意 WKTWriter(2) 输出 POINT (1 2)——Z 和 M 都被丢了。这在“入库前顺手 write 一下做日志”时非常危险:日志里看不出 M 丢了,等到需要度量值的时候就找不回来了。

6.4.5 CheckOrdinatesFilter:输出维度还会被实际数据“打折”

WKTWriter 内部在写之前会走一遍 CheckOrdinatesFilter:

OrdinateFormat formatter = getFormatter(geometry);
// ...
CheckOrdinatesFilter cof = new CheckOrdinatesFilter(this.outputOrdinates);
geometry.apply(cof);

它的作用是:如果几何里所有点的 Z(或 M)都是 NaN,就把该维度从输出集合里去掉,避免输出一堆 POINT Z (1 2 NaN)。

这带来一个不太直观的结果:

WKTWriter w3 = new WKTWriter(3);
Geometry g = new GeometryFactory().createPoint(new Coordinate(1, 2));  // 只有 XY
System.out.println(w3.write(g));   // 输出 POINT (1 2),没有 Z

即使 outputDimension = 3,只要数据里没有有效的 Z,就不会输出 Z 标记。“设了维度”不等于“一定会输出维度”,这是一条重要区别。


6.5 WKBReader 与 WKBWriter

6.5.1 公开 API

public class WKBReader {
  public static byte[] hexToBytes(String hex);
  public WKBReader();
  public WKBReader(GeometryFactory geometryFactory);
  public Geometry read(byte[] bytes) throws ParseException;
  public Geometry read(InStream is) throws ParseException;
}

public class WKBWriter {
  public static String toHex(byte[] bytes);
  public static String bytesToHex(byte[] bytes);   // 已弃用,等价于 toHex

  public WKBWriter();
  public WKBWriter(int outputDimension);
  public WKBWriter(int outputDimension, boolean includeSRID);
  public WKBWriter(int outputDimension, int byteOrder);
  public WKBWriter(int outputDimension, int byteOrder, boolean includeSRID);

  public void setOutputOrdinates(EnumSet<Ordinate> outputOrdinates);
  public EnumSet<Ordinate> getOutputOrdinates();
  public byte[] write(Geometry geom);
  public void write(Geometry geom, OutStream os) throws IOException;
}

几个要点:

  • 字节序是 int 不是枚举,取值来自 ByteOrderValues.BIG_ENDIAN(1)与 LITTLE_ENDIAN(2)。网上有文章写 new WKBWriter(2, ByteOrder.LITTLE_ENDIAN),那是错的,编译不过。
  • 没有 setByteOrder 方法,字节序只能在构造器里定。
  • includeSRID 也只能在构造器里定,没有 setter。
  • read(InStream) 这个重载用于“从流里连续读多个几何”,配合 ByteArrayInStream / InputStreamInStream 使用。

6.5.2 构造器组合速查

构造器 默认维度 字节序 含 SRID 适用场景
new WKBWriter() 2 大端 否 标准 WKB,跨库兼容
new WKBWriter(3) 3 大端 否 带高程
new WKBWriter(3, true) 3 大端 是 带高程 + SRID(EWKB)
new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN) 2 小端 否 与 x86 原生字节序一致,某些库偏好
new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN, true) 2 小端 是 PostGIS 默认风格(小端 + SRID)

跨库兼容的默认选择是大端 + 不含 SRID(也就是 new WKBWriter()),因为 OGC 标准规定 XDR(大端)是默认字节序。PostGIS 默认用小端并带 SRID(EWKB),两边交换时要么统一构造参数,要么在读取端都能兼容——JTS 的 WKBReader 两种都能读。

6.5.3 十六进制互转

import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.io.WKBWriter;

public class HexDemo {

  public static void main(String[] args) throws Exception {
    GeometryFactory gf = new GeometryFactory();
    byte[] wkb = new WKBWriter().write(
        new org.locationtech.jts.io.WKTReader(gf).read("POINT (1 2)"));

    // 字节 → 十六进制(大写)
    String hex = WKBWriter.toHex(wkb);
    System.out.println("hex = " + hex);

    // 十六进制 → 字节 → 几何
    byte[] back = WKBReader.hexToBytes(hex);
    System.out.println("往返 = " + new WKBReader(gf).read(back));
  }
}

输出:

hex = 00000000013FF00000000000004000000000000000
往返 = POINT (1 2)

toHex 输出大写十六进制。如果上游给的是小写(PostGIS 的 ST_AsEWKB 常是小写或带 \x 前缀),hexToBytes 也能处理大小写;但带 \x 前缀的字符串要先去掉前缀,否则会因为多两个字符而报“奇数长度”或解析错位。

// 处理 PostGIS 风格的前缀
String pgHex = "\\x000000000101000000000000000000F03F0000000000000040";
String clean = pgHex.startsWith("\\x") ? pgHex.substring(2) : pgHex;
byte[] bytes = WKBReader.hexToBytes(clean);

6.5.4 读写流的批量处理

逐个 new WKBWriter() 有对象创建开销,批量场景建议复用 writer 并用 write(Geometry, OutStream):

import java.io.ByteArrayOutputStream;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.OutputStreamOutStream;
import org.locationtech.jts.io.WKBWriter;

public class BatchWriteDemo {

  public static void writeAll(Iterable<Geometry> geoms) throws Exception {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    OutputStreamOutStream out = new OutputStreamOutStream(baos);
    WKBWriter writer = new WKBWriter(2, true);

    int n = 0;
    for (Geometry g : geoms) {
      writer.write(g, out);
      n++;
    }
    System.out.println("写出 " + n + " 个几何, " + baos.size() + " 字节");
  }
}

对应的读取端用 WKBReader.read(InStream) 循环读,直到流耗尽:

import java.io.ByteArrayInputStream;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.InputStreamInStream;
import org.locationtech.jts.io.WKBReader;

public static java.util.List<Geometry> readAll(byte[] bytes, GeometryFactory gf) throws Exception {
  java.util.List<Geometry> out = new java.util.ArrayList<Geometry>();
  InputStreamInStream in = new InputStreamInStream(new ByteArrayInputStream(bytes));
  WKBReader reader = new WKBReader(gf);
  while (true) {
    try {
      out.add(reader.read(in));
    } catch (org.locationtech.jts.io.ParseException e) {
      break;   // 流结束(或数据损坏,生产环境要区分)
    }
  }
  return out;
}

注意:这种“连着写多个 WKB”没有分隔符,读取端完全靠“解析完一个几何后刚好到下一个几何的头部”来推进。如果中间有一个字节损坏,后面全部错位——所以生产环境更推荐每个几何单独一帧(长度前缀 + WKB),或改用 TWKB(自带长度字段)。


6.6 WKB 字节布局逐段解析

6.6.1 规范结构

WKB 的设计极其简洁:一个字节的字节序标记,四个字节的类型码,然后是几何体。JTS 的 WKBWriter 类注释里有完整的伪代码定义,转录如下:

byte     byteOrder;      // 0 = XDR(大端), 1 = NDR(小端)
uint32   wkbType;        // 几何类型 + 维度位 + SRID 位

Point:              uint32 wkbType; double x; double y; [double z]; [double m];
LineString:         uint32 wkbType; uint32 numCoords; Point points[numCoords];  // 不含各自头部
Polygon:            uint32 wkbType; uint32 numRings; LinearRing rings[numRings];
LinearRing(=LineString): uint32 numCoords; Point points[numCoords];
MultiPoint:         uint32 wkbType; uint32 numElems; WKBPoint elems[numElems];   // 每个含完整头部
MultiLineString:    uint32 wkbType; uint32 numElems; WKBLineString elems[numElems];
MultiPolygon:       uint32 wkbType; uint32 numElems; WKBPolygon elems[numElems];
GeometryCollection: uint32 wkbType; uint32 numElems; WKBGeometry elems[numElems];

三个关键点:

  1. LineString 内部的点是裸坐标,不重复写类型头;而 MultiPoint 的每个成员都带完整 WKB 头。这是 WKB 里最容易混淆的地方。
  2. 类型码是 uint32,但只用低 8 位表示几何类型(1~7)。
  3. 维度信息编码在类型码的高位:第 31 位是 Z,第 30 位是 M(EWKB 约定);SRID 在第 29 位。

6.6.2 逐字节拆解一个 Point

取 POINT (1 2) 的十六进制:

00000000013FF00000000000004000000000000000

按字段分组:

00                <- 字节序:0 = 大端(XDR)
00000001          <- 类型码:1 = Point(无维度位、无 SRID 位)
3FF0000000000000  <- X = 1.0 的 IEEE 754 双精度(大端)
4000000000000000  <- Y = 2.0 的 IEEE 754 双精度(大端)

总计 1 + 4 + 8 + 8 = 21 字节

验证一下:IEEE 754 双精度的 1.0 是 0x3FF0000000000000,大端写出来就是 3F F0 00 00 00 00 00 00;2.0 是 0x4000000000000000,大端是 40 00 00 00 00 00 00 00。和上面的字节完全对得上。

(注意:本章开头 6.1.3 那段十六进制是较早的输出,与这里实测的格式一致;如果你手头的 JTS 版本输出以 0101000000 开头,说明写的是小端,见 6.5.2 的构造器选择。)

6.6.3 拆解一个 Polygon

取本章开头那个 10×10 的矩形,用如下代码把每个字段解码出来:

byte[] wkb = new WKBWriter().write(
    new WKTReader(gf).read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"));

System.out.println("总长度 = " + wkb.length);
System.out.println("字节序 = " + wkb[0]);

java.nio.ByteBuffer bb = java.nio.ByteBuffer.wrap(wkb)
    .order(java.nio.ByteOrder.BIG_ENDIAN);
System.out.println("类型码 = " + bb.getInt(1));
System.out.println("环数   = " + bb.getInt(5));
int numPts = bb.getInt(9);
System.out.println("点数   = " + numPts);
for (int i = 0; i < numPts; i++) {
  int off = 13 + i * 16;
  System.out.printf("  pt%d = (%.1f, %.1f)%n", i, bb.getDouble(off), bb.getDouble(off + 8));
}

输出(本机实跑):

总长度 = 93
字节序 = 0
类型码 = 3
环数   = 1
点数   = 5
  pt0 = (0.0, 0.0)
  pt1 = (10.0, 0.0)
  pt2 = (10.0, 10.0)
  pt3 = (0.0, 10.0)
  pt4 = (0.0, 0.0)

对应的字段布局:

偏移(字节) 长度 内容 本例取值
0 1 字节序 00(大端)
1 4 类型码 00000003(Polygon)
5 4 环数 00000001
9 4 第一个环的点数 00000005
13 16×5 5 个点的 XY 首尾都是 (0,0),环闭合

几个可验证的细节:

  • 10.0 的 IEEE 754 双精度是 0x4024000000000000,所以你会在这 93 字节里反复看到 4024000000... 的片段。
  • 环必须闭合,因此第一个环的第 1 个点和第 5 个点完全相同——这也是 numPts = 5 而不是 4 的原因。
  • 总长度验算:1(字节序) + 4(类型) + 4(环数) + 4(点数) + 5×16(坐标) = 93 字节,与程序输出一致。

注意 ByteBuffer 的 order 必须与数据一致:上面写死 BIG_ENDIAN,如果要兼容小端数据,应该先读 wkb[0] 判断,再设置 order。

6.6.4 维度位与 SRID 位

类型码的位布局(EWKB 约定,JTS 的 WKBWriter 按此实现):

 31 30 29 28                     8 7                     0
┌──┬──┬──┬──────────────────────┬────────────────────────┐
│Z │M │SR│       reserved       │      geometry type     │
└──┴──┴──┴──────────────────────┴────────────────────────┘
  │  │  └─ 0x20000000 = 有 SRID
  │  └──── 0x40000000 = 有 M
  └─────── 0x80000000 = 有 Z

源码里的实际写法(WKBWriter.writeGeometryType):

int flag3D = 0x80000000;          // Z 位
int typeInt = geometryType | flag3D;
typeInt |= includeSRID ? 0x20000000 : 0;
writeInt(typeInt, os);
if (includeSRID) {
  writeInt(g.getSRID(), os);
}

所以:

输出 整段十六进制(大端) 类型码含义
POINT (1 2) 00000000 01 3FF0... 00000001,纯 2D
POINT Z (1 2 3) 00800000 01 3FF0... 4000... 4008... 80000001,Z 位置 1
POINT (x y) + SRID 4326 00200000 01 000010E6 405D... 20000001,SRID 位 + 4 字节 4326
POINT Z (1 2 3) + SRID 4326 00A00000 01 000010E6 3FF0... A0000001,Z 位 + SRID 位

(表格里的空格只是为了分组阅读,实际 WKB 是连续字节;000010E6 是 4326 的十六进制。)

M 值的编码要特别小心。JTS 的 writeGeometryType 里只有 Z 位(0x80000000)与 SRID 位(0x20000000)的处理,M 位(0x40000000)没有出现——M 只在坐标写入阶段多写一个 double。这意味着 JTS 输出的“带 M”WKB 与 PostGIS 的 EWKB 在类型码上不一致,跨库交换 M 值必须做兼容测试。可靠的做法是:需要 M 值时,用 WKT(POINT M(...))或带自定义协议的 GeoJSON 交换,不要指望裸 WKB。


6.7 SRID 与 EWKB

6.7.1 SRID 在 WKB 里的位置

标准 WKB(OGC)不含 SRID,SRID 由外部元数据(表的列定义、GeoJSON 的 crs 字段)携带。PostGIS 扩展了 WKB,形成 EWKB:在类型码后紧跟 4 字节 SRID。

标准 WKB:  [字节序][类型]           [几何数据]
EWKB:      [字节序][类型|SRID位][SRID][几何数据]

JTS 的 WKBWriter 通过构造器参数 includeSRID 支持 EWKB:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.io.WKBWriter;
import org.locationtech.jts.io.WKTReader;

public class SridDemo {

  public static void main(String[] args) throws Exception {
    GeometryFactory gf = new GeometryFactory();
    Geometry g = new WKTReader(gf).read("POINT (116.397 39.909)");
    g.setSRID(4326);

    // 不含 SRID
    byte[] plain = new WKBWriter(2, false).write(g);
    System.out.println("plain len = " + plain.length + " hex = " + WKBWriter.toHex(plain));

    // 含 SRID(EWKB)
    byte[] ewkb = new WKBWriter(2, true).write(g);
    System.out.println("ewkb  len = " + ewkb.length + " hex = " + WKBWriter.toHex(ewkb));

    // 两者都能读回来
    WKBReader reader = new WKBReader(gf);
    System.out.println("读 plain: SRID = " + reader.read(plain).getSRID());
    System.out.println("读 ewkb : SRID = " + reader.read(ewkb).getSRID());
  }
}

输出(本机实跑):

plain len = 21 hex = 0000000001405D196872B020C54043F45A1CAC0831
ewkb  len = 25 hex = 0020000001000010E6405D196872B020C54043F45A1CAC0831
读 plain: SRID = 0
读 ewkb : SRID = 4326

对比两段十六进制,差异只在头部:00000000 01 变成了 00200000 01,后面多出 000010E6(4326)。坐标部分完全一致。

长度差正好 4 字节,就是 SRID。关键结论:不带 includeSRID 写出再读回,SRID 会丢成 0。这是数据入库后“坐标系莫名变成 0”的典型原因。

6.7.2 只有顶层几何带 SRID

WKBWriter 的类注释写得很明确:

Only the top-level geometry has the SRID included. This assumes that all geometries in a collection have the same SRID as the collection.

也就是说,对于 MultiPolygon 或 GeometryCollection,只有最外层写 SRID,内部成员不重复写。这既省空间,也隐含一个假设:集合内所有成员的 SRID 一致。如果你的数据违反了这个假设(比如一个集合里混了 4326 和 3857),写出后读回会全部变成外层 SRID——静默丢信息。

代码上也印证了这点:writeGeometryCollection 里会临时把 includeSRID 置 false,写完全部成员再恢复:

boolean originalIncludeSRID = this.includeSRID;
this.includeSRID = false;
// ... 写所有成员 ...
this.includeSRID = originalIncludeSRID;

6.7.3 读 SRID 的正确姿势

WKBReader 会自动识别 EWKB 并设置 SRID,不需要额外配置。但如果输入是标准 WKB(无 SRID),读出来的几何 getSRID() 就是 0,需要业务层补:

Geometry g = wkbReader.read(bytes);
if (g.getSRID() == 0) {
  g.setSRID(expectedSrid);   // 从配置或列定义里取
}

反过来,如果输入是 EWKB 而你的 GeometryFactory 也带 SRID,谁优先?答案是 WKB 里的 SRID 优先——WKBReader 会读出来覆盖。这一点要清楚,否则会出现“配置写了 4326,读出来却是 3857”的困惑。

6.7.4 PostGIS 互换的注意事项

场景 做法
JTS → PostGIS 用 new WKBWriter(2, true) 生成 EWKB,PG 端 ST_GeomFromEWKB(bytea)
PostGIS → JTS ST_AsEWKB(geom) 取出 bytea,用 WKBReader 直接读(自带 SRID)
不含 SRID 的交换 用 ST_GeomFromWKB(bytes, srid) 显式传 SRID
坐标顺序 PostGIS 的 ST_AsBinary 输出是小端;JTS 默认大端。都符合规范,读写端必须能双向兼容

最后一条要特别强调:字节序不影响语义,但会让十六进制字符串看起来完全不同。同一句 POINT (1 2),大端是 0000000001...3FF0...,小端是 0101000000...F03F...。如果你的日志对比脚本按字符串比对,会误判为“数据不一致”。


6.8 空几何与特殊值

6.8.1 空几何在 WKB 里的编码

空几何没有统一编码,按类型分:

几何 WKB 编码方式
空 Point 坐标为 NaN(0x7FF8000000000000)
空 LineString 点数 = 0
空 Polygon 环数 = 0
空 Multi* / Collection 元素数 = 0
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKBWriter;
import org.locationtech.jts.io.WKTReader;

public class EmptyDemo {

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

    String[] wkts = {
        "POINT EMPTY", "LINESTRING EMPTY", "POLYGON EMPTY",
        "MULTIPOINT EMPTY", "GEOMETRYCOLLECTION EMPTY"
    };
    for (String wkt : wkts) {
      byte[] b = writer.write(reader.read(wkt));
      System.out.printf("%-24s %2d 字节  %s%n", wkt, b.length, WKBWriter.toHex(b));
    }
  }
}

输出(本机实跑):

POINT EMPTY              21 字节  00000000017FF80000000000007FF8000000000000
LINESTRING EMPTY         9 字节  000000000200000000
POLYGON EMPTY            9 字节  000000000300000000
MULTIPOINT EMPTY         9 字节  000000000400000000
GEOMETRYCOLLECTION EMPTY 9 字节  000000000700000000

看 POINT EMPTY 的那 21 字节:类型码 1,然后两个 7FF8000000000000 —— 这正是 Double.NaN 的 IEEE 754 位模式。

这带来一个不对称行为,实测对比如下:

输入 WKTReader 读出的结果 WKBWriter 写出的字节 WKBReader 读回的结果
POINT EMPTY 空点(getCoordinate() 为 null) 两个 NaN 坐标 POINT EMPTY
POINT (NaN NaN) 非空点(坐标 NaN,isValid() 为 false) 与上一行完全相同的字节 POINT EMPTY

也就是说:

  • WKB 层面确实无法区分两种输入——它们字节相同。
  • WKT 读入不会归一化(NaN 点保持非空),但 WKB 读入会归一化成空几何。
  • 同一个“语义上空的点”,经过 WKT → WKB → WKBReader 之后会变成真正的 POINT EMPTY。

所以在数据处理链路上,如果你希望 NaN 点被视为空,最省事的做法是“过一遍 WKB 往返”;反过来,如果你依赖 NaN 点保留原样,别让它走 WKB。

6.8.2 NaN 与 Infinity 的传播

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;

GeometryFactory gf = new GeometryFactory();
Geometry nanPt = gf.createPoint(new Coordinate(Double.NaN, Double.NaN));
System.out.println("empty = " + nanPt.isEmpty() + ", valid = " + nanPt.isValid());

Geometry infPt = gf.createPoint(new Coordinate(Double.POSITIVE_INFINITY, 0));
System.out.println(infPt);

输出(本机 jts-core 1.20.0 实跑):

empty = false, valid = false
POINT (Inf 0)

两行都说明:JTS 不阻止你用 NaN 或 Infinity 构造几何,也不会把它“归一成空”。这样的几何后续参与面积、长度、谓词计算时,会得到 NaN 或直接抛异常,问题被推迟到很远的地方才爆发。所以生产代码应该在入口处做数值校验:

public static void assertFinite(Geometry g) {
  for (int i = 0; i < g.getNumGeometries(); i++) {
    Coordinate[] pts = g.getGeometryN(i).getCoordinates();
    for (Coordinate c : pts) {
      if (!Double.isFinite(c.x) || !Double.isFinite(c.y)) {
        throw new IllegalArgumentException("non-finite coordinate: " + c);
      }
    }
  }
}

(Double.isFinite 是 Java 8 就有的 API,可以直接用。)

6.8.3 空集合嵌套

空的 GEOMETRYCOLLECTION 与非空的集合里包含空成员,是两种不同情况:

Geometry emptyColl = reader.read("GEOMETRYCOLLECTION EMPTY");
System.out.println(emptyColl.getNumGeometries());   // 0

Geometry mixed = reader.read("GEOMETRYCOLLECTION (POINT (1 2), LINESTRING EMPTY)");
System.out.println(mixed.getNumGeometries());       // 2
System.out.println(mixed.getGeometryN(1).isEmpty()); // true

序列化时不会自动剔除空成员,WKB 会老老实实把它们写进去。如果你的下游不希望看到空成员,要在写之前自己 GeometryExtracter 过滤或 GeometryFixer 清理。


6.9 性能与批量读写

6.9.1 三种序列化的成本量级

操作 相对成本 说明
WKBWriter.write 1× 纯字节拷贝,最快
WKTWriter.write 5~15× 数字格式化(DecimalFormat)是大头
WKTReader.read 10~30× 词法分析 + 逐字符扫描
WKBReader.read 1~2× 直接读字节,几乎无解析
GeoJsonWriter.write 10~20× JSON 拼装 + 数字格式化

结论很直接:高频路径用 WKB,不要用 WKT。日志里写 WKT 可以接受(低频),但如果每处理一个几何就 toString() 一次,性能会被吃掉一大半。

6.9.2 复用对象

WKTReader / WKBReader 都有内部状态(缓冲区、位置指针),不是线程安全的,但可以复用:

// 单线程批量:复用一个 reader
WKTReader reader = new WKTReader(gf);
for (String wkt : list) {
  Geometry g = reader.read(wkt);
  // ...
}

WKTWriter 的 OrdinateFormat 内部有 synchronized 的 format 方法,意味着多线程共享会串行化。批量并行场景应该每线程一个 writer。

WKBWriter 相对无状态(除了配置),复用安全;但 write(Geometry, OutStream) 会写共享的 ByteOrderValues 静态缓冲区吗?看源码是每次新建局部 byte[] buf,所以是安全的。

6.9.3 大文件的流式处理

WKTFileReader 支持从文件里读多个 WKT(用空白/换行分隔),并支持 setLimit / setOffset:

import java.io.File;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTFileReader;
import org.locationtech.jts.io.WKTReader;

WKTReader reader = new WKTReader();
WKTFileReader fileReader = new WKTFileReader(new File("geoms.wkt"), reader);
fileReader.setLimit(1000);     // 最多读 1000 个
fileReader.setOffset(10);      // 跳过前 10 个

java.util.List<Geometry> geoms = fileReader.read();

对应的 WKB 版本是 WKBHexFileReader(读十六进制文本文件,每行一个 WKB)。这两个类在“样本数据快速载入测试”时很好用,但不适合生产 ETL——它们没有错误恢复,遇到一条坏数据就中断。

6.9.4 尺寸对比实测

import java.nio.charset.StandardCharsets;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKBWriter;
import org.locationtech.jts.io.WKTWriter;

public class SizeCompare {

  public static void main(String[] args) {
    GeometryFactory gf = new GeometryFactory();
    Coordinate[] pts = new Coordinate[1000];
    for (int i = 0; i < pts.length; i++) {
      pts[i] = new Coordinate(116.397428 + i * 1e-5, 39.90923 + i * 1e-5, i * 0.1);
    }
    Geometry line = gf.createLineString(pts);

    int wkb = new WKBWriter(3).write(line).length;

    WKTWriter plain = new WKTWriter(3);
    int wktPlain = plain.write(line).getBytes(StandardCharsets.UTF_8).length;

    WKTWriter six = new WKTWriter(3);
    six.setPrecisionModel(new PrecisionModel(1e6));
    int wktSix = six.write(line).getBytes(StandardCharsets.UTF_8).length;

    System.out.printf("WKB(3D)        = %d 字节%n", wkb);
    System.out.printf("WKT 默认精度    = %d 字节 (%.1f%%)%n", wktPlain, wktPlain * 100.0 / wkb);
    System.out.printf("WKT 6 位精度    = %d 字节 (%.1f%%)%n", wktSix, wktSix * 100.0 / wkb);
  }
}

输出(1000 个 3D 点,本机实跑):

WKB(3D)        = 24009 字节
WKT 默认精度    = 34492 字节 (143.7%)
WKT 6 位精度    = 25601 字节 (106.6%)

对比表:

格式 体积(字节) 相对 WKB 精度
WKB(3D) 24,009 100% 无损(双精度)
WKT(默认) 34,492 143.7% 文本全精度
WKT(6 位小数) 25,601 106.6% 6 位小数,有损

几个观察:

  • 默认精度的 WKT 比 WKB 大约大 44%。比例随数据变化:坐标小数位越多,WKT 越大;坐标是整数时,WKT 可能反而更小。
  • 降到 6 位精度能省约 26%(34492 → 25601),但代价是精度永久损失,且体积仍比 WKB 大 6.6%。原因是 WKT 的开销大头是分隔符、括号与类型标记,不是数字本身。
  • 结论:要无损就用 WKB;要更小就得靠 TWKB 这类有损编码(见第 7 章)。低精度 WKT 只适合“人读 + 精度不那么重要”的场景。

6.10 常见问题与避坑

6.10.1 精度与数值

Q:WKT 写出来的坐标位数太多,能不能按固定小数位输出? A:WKTWriter 只能通过 setPrecisionModel 控制“最大小数位数”,而且行为是“多余位丢掉、尾部 0 省略”,不是固定位数补零。要固定位数只能自己格式化后拼字符串。参考 6.3.2。

Q:设了 PrecisionModel(1e6),为什么输出有时 6 位小数、有时 5 位? A:因为 DecimalFormat 会去掉尾随 0。116.397428 是 6 位,39.90923 是 5 位(第 6 位是 0)。要严格对齐请在断言里用数值比较,不要做字符串比较。

Q:POINT (1.0 2.0) 写出来是 POINT (1 2),精度丢了吗? A:没有。1.0 与 1 是同一个双精度值,文本表示不同而已。DecimalFormat 会输出最短的等值形式。

Q:坐标特别大或特别小时,WKT 会不会变科学计数法? A:OrdinateFormat 用的是 DecimalFormat,不会自动转科学计数法(源码注释里把“用科学计数法”列为 FUTURE work)。但极大/极小值会输出很长的小数,注意日志体积。

6.10.2 维度相关

Q:WKT 写出去再读回来,Z 值丢了。 A:检查 writer 是否为 new WKTWriter(3) 或 (4)。默认构造器是 2 维,静默丢 Z 和 M。参考 6.4.3。

Q:几何里明明有 Z,为什么输出没有 Z 标记? A:两种情况:① writer 维度是 2;② 所有点的 Z 都是 NaN,CheckOrdinatesFilter 把维度去掉了。参考 6.4.5。

Q:M 值怎么读进来又怎么写出去? A:用 WKTReader 读 POINT M (1 2 4) 得到 CoordinateXYM,写时用 new WKTWriter(3) 加 setOutputOrdinates(Ordinate.createXYM())。注意 3 维 writer 下 Z 与 M 只能二选一(源码里是 else if)。参考 6.4.3。

Q:GeometryCollection 里不同成员的维度不同,能写出吗? A:WKT 层面能写,但只有一个 Z/M 标记作用于整体。混合维度集合建议拆开处理,或统一提升到最高维度再写。

6.10.3 WKB 相关

Q:从 PostGIS 拿到的 WKB 十六进制带 \\x 前缀,直接用 hexToBytes 报错。 A:先去掉前缀:hex.startsWith("\\x") ? hex.substring(2) : hex。参考 6.5.3。

Q:WKB 读出来 SRID 是 0。 A:说明数据是标准 WKB(不含 SRID)。要么让上游用 EWKB,要么读取后手动 setSRID(...)。参考 6.7.1。

Q:new WKBWriter(2, ByteOrder.LITTLE_ENDIAN) 编译不过。 A:参数是 int 不是 ByteOrder。用 ByteOrderValues.LITTLE_ENDIAN(值为 2)。参考 6.5.1。

Q:跨语言交换后坐标完全乱掉。 A:九成是字节序不一致或把 MultiPoint 的成员当成裸坐标解析。先用 WKBWriter.toHex 打印前 5 个字节,确认字节序与类型码。参考 6.6。

Q:连续写多个 WKB 到同一个流,读取端错位。 A:WKB 没有长度前缀,损坏一个字节后面全废。生产环境请自己加“长度 + 内容”的帧格式,或改用 TWKB(自带 size 字段)。参考 6.5.4。

6.10.4 空几何与异常值

Q:POINT EMPTY 调 getCoordinate() 报 NPE。 A:空点的 getCoordinate() 返回 null。先判 isEmpty()。参考 6.8.1。

Q:POINT (NaN NaN) 到底算不算空? A:WKT 读入后不算空(isEmpty() 为 false、isValid() 为 false),但走一遍 WKB 往返后会变成 POINT EMPTY。参考 6.8.1 的对比表。

Q:读 POINT (Inf 1) 报错。 A:1.20.0 不支持 Inf/-Inf 字面量,1.21 才支持。确认版本,或改用极大有限值。参考 6.2.5。

Q:POLYGON 里有洞,WKB 长度算不对。 A:每个环都占 4 字节环头 + 点数 × 16 字节,别忘了洞也是环。总长度 = 1 + 4 + 4 + Σ(4 + n_i × 16)。

6.10.5 性能与线程

Q:日志里打 WKT 导致吞吐暴跌。 A:WKTWriter 的数字格式化很贵。高频路径改成“只在 DEBUG 打、或打 WKB 摘要(长度 + 包络)”。

Q:多线程下 WKT 输出串行化严重。 A:OrdinateFormat.format 是 synchronized 的。每线程一个 WKTWriter,或改用 WKBWriter。

Q:WKTReader 能声明成单例 Bean 吗? A:不能。它有内部状态(位置指针、缓冲区),并发解析会串数据。用 ThreadLocal 或每次 new。参考第 23 章的线程安全表。

Q:批量写 100 万个几何,怎么最快? A:用 WKBWriter 复用实例 + write(Geometry, OutStream),避免每次创建 ByteArrayOutputStream;输出到 BufferedOutputStream,不要逐个 flush。


6.11 本章小结

  • JTS 的 WKT/WKB 全在 jts-core 的 org.locationtech.jts.io 包里,零额外依赖;GeoJSON 与 TWKB 才需要 jts-io-common。
  • WKTReader 的 API 只有两个构造器、三个开关、两个 read;SRID 由 GeometryFactory 带入,WKT 文本本身不含 SRID。
  • 解析失败会抛两种异常:语法错误是 ParseException(受检),结构错误(环不闭合、点数不足)是 IllegalArgumentException(非受检)。只 catch 前者会漏。
  • WKTWriter 没有 setTrim/setOld3D;精度靠 setPrecisionModel,维度靠构造器维度 + setOutputOrdinates。
  • setFormatted(true) 对 write(Geometry) 无效——源码里写死 false。要格式化请用 writeFormatted(...)。
  • PrecisionModel 的 “maximumSignificantDigits” 实际是“最大小数位数”,FIXED 模型下取 1 + ceil(log10(scale)),比 log10(scale) 多 1。
  • 不加 Z 标记时 JTS 会按坐标个数推断维度,但不该依赖;跨系统交换务必显式写 Z/M/ZM。
  • WKB 结构是“字节序 + 类型码 + 几何体”;LineString 内部的点是裸坐标,Multi* 的每个成员都带完整头部。
  • 维度位 0x80000000、SRID 位 0x20000000;JTS 不写 M 位 0x40000000,带 M 的跨库交换要专门测试。
  • 只有顶层几何写 SRID,集合内成员默认同 SRID;new WKBWriter(2, true) 才生成 EWKB。
  • 空点用 NaN 坐标编码;WKT 读入的 NaN 点不算空,WKB 读入会归一成空几何。
  • 性能上 WKB 读写比 WKT 快一个量级;WKT 体积约为 WKB 的 1.4 倍,降精度只能省约四分之一,不值得。

相关资源