第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,这决定了三件事:
- 产出几何的工厂归属。后续调用
geom.getFactory()拿到的就是它。 - 坐标序列的实现。默认
GeometryFactory用CoordinateArraySequenceFactory(源码getDefaultCoordinateSequenceFactory()返回它),你也可以显式传PackedCoordinateSequenceFactory得到紧凑的堆外实现。注意CoordinateArraySequence的默认维度是 3(源码private int dimension = 3),所以即便几何只有 XY,序列的getDimension()也可能返回 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);
}
}
三条推论:
outputDimension == 2时,怎么设都不会输出 Z/M。想输出维度必须先new WKTWriter(3)或new WKTWriter(4)。outputDimension == 3且同时传 XYZM 时,只会输出 Z(不输出 M)——因为 Z 分支命中了else if就不再走 M。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];
三个关键点:
LineString内部的点是裸坐标,不重复写类型头;而MultiPoint的每个成员都带完整 WKB 头。这是 WKB 里最容易混淆的地方。- 类型码是 uint32,但只用低 8 位表示几何类型(1~7)。
- 维度信息编码在类型码的高位:第 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 倍,降精度只能省约四分之一,不值得。
相关资源
- JTS 官方仓库:https://github.com/locationtech/jts
- JTS Javadoc(
io包):https://locationtech.github.io/jts/javadoc/org/locationtech/jts/io/package-summary.html - OGC Simple Features 规范:https://www.ogc.org/standards/sfa
- PostGIS EWKB 说明:https://postgis.net/docs/using_postgis_dbmanagement.html
- 本系列第 04 章(几何对象模型详解):https://znlgis.github.io/gis/tutorial/jts/第04章-几何对象模型详解/
- 本系列第 05 章(几何创建编辑与仿射变换):https://znlgis.github.io/gis/tutorial/jts/第05章-几何创建编辑与仿射变换/
- 本系列第 07 章(GeoJSON 与 TWKB 数据交换):https://znlgis.github.io/gis/tutorial/jts/第07章-GeoJSON与TWKB数据交换/
- 本系列第 18 章(精度模型与数值鲁棒性):https://znlgis.github.io/gis/tutorial/jts/第18章-精度模型与数值鲁棒性/