第21章 - 源码编译、测试与贡献
上一章讨论的是“怎么把 JTS 用快”,这一章换个视角:把 JTS 当成一份可以读、可以改、可以参与维护的开源代码。很多团队用 JTS 用了好几年,却始终停留在“调 API”的层面——遇到一个诡异的 TopologyException 只能换个参数绕过去,遇到一个疑似 bug 只能等社区修,想加一个专门的几何能力只能在业务代码里层层包壳。真正的分水岭在于能不能打开源码,定位到具体算法,改一行、加一个测试、跑通全量回归。
本章解决三个具体问题。第一,如何从零把 JTS 1.21.0-SNAPSHOT 编译起来:JDK 与 Maven 的版本要求、多模块结构、profile 开关、跳过测试与只编译单模块的技巧。第二,如何读懂它的测试体系并写出符合项目习惯的测试:JUnit 单测与 XML 声明式测试双轨制的分工,GeometryTestCase 这类工具基类怎么用,新增一个算法类需要配哪些测试与元数据。第三,如何按 JTS 的规矩提交改动:license header、Javadoc、PMD/Checkstyle 门禁、GitHub PR 流程,以及维护者视角的发布流程。
读完后你应该能做到:在 IDEA 里把 JTS 全量导入、独立改一个有问题的算法、补上单测与 XML 用例、跑 mvn verify 全部绿灯,并写出一份能让维护者愿意合入的 PR。前置知识是第 3 章(核心架构与源码结构)和第 17 章(有效性与 Coverage 质检);如果还没读过,建议先补一遍,本章会直接引用其中提到的包与类。
21.1 为什么要读 JTS 源码
21.1.1 三个真实场景
场景一:结果与预期不一致。 业务方反馈“两个相邻地块做 union 之后多了一个细长条”。你在业务层换精度模型、换 OverlayNG、加 GeometryFixer,都不稳定。这时候唯一可靠的办法是打开源码:Geometry.union(Geometry) 到底走哪条路径?GeometryOverlay.overlay() 在什么条件下走 OverlayNGRobust,什么条件下走 SnapIfNeededOverlayOp?看清这条调用链,才知道该在哪一层加干预。
场景二:想加一个 JTS 没有的能力。 比如“把线按最大偏转角分段”“把面按面积阈值切成两类”。这些能力 JTS 没有现成 API,但它的扩展点(GeometryTransformer、GeometryFilter、测试框架的静态函数注册)完全够用。读懂源码里同类算法的写法,能在两小时内写出一个和官方风格一致的类,而不是在业务代码里堆一坨匿名内部类。
场景三:怀疑踩到了 bug。 JTS 有 625 个 core 主源码文件、388 个单测类、129 个 XML 测试文件,绝大多数的边界情况都被覆盖了。如果结果真的很怪,大概率不是 bug,而是你误解了 API 的边界条件(比如把非空 GeometryCollection 传给 intersection、M 值在 WKB 往返中丢失、负缓冲把多边形整个吃掉)。读源码能让你在十分钟内确认“这是我的问题还是库的问题”。
21.1.2 读源码的投入产出
| 目标 | 只看 Javadoc | 读源码 | 说明 |
|---|---|---|---|
| 调对一个 API 参数 | 通常够 | 够 | 大部分 Javadoc 写得不错 |
| 解释一次异常结果 | 常不够 | 够 | 异常往往来自内部策略选择,如精度模型、snap 回退 |
| 加一个自定义算法 | 不够 | 够 | 需要模仿既有类的结构与测试方式 |
| 判断是否为 bug | 不够 | 够 | 需要看断言与前置校验 |
| 提交一个可合入的 PR | 完全不够 | 必须 | 规范、门禁、测试要求都写在代码与构建脚本里 |
一句话总结:API 使用者读 Javadoc,能力扩展者读源码,贡献者读源码加构建脚本。
21.1.3 本章使用的源码基线
本章所有结论都基于 D:/self/code/jts 这份检出,仓库版本 1.21.0-SNAPSHOT,最近一次正式发布为 1.20.0(2024-09-18)。几个关键坐标先记住:
- 构建坐标:
org.locationtech.jts:jts-core、org.locationtech.jts.io:jts-io-common、org.locationtech.jts:jts-app、org.locationtech.jts:jts-tests、org.locationtech.jts:jts-lab。 - 编译目标:
maven.compiler.source/target = 1.8,即产出的 class 文件是 Java 8 字节码。 - 测试依赖:JUnit 4.13.1(注意是 JUnit 4,不是 5)。
- 质量工具:PMD 7.22.0、Checkstyle 8.44,规则文件放在
build-tools模块里。 - 许可证:EPL 2.0 与 EDL 1.0 双许可,每个源文件头部必须带标准 header。
21.2 构建环境准备
21.2.1 版本矩阵
JTS 的编译目标虽然是 Java 8,但用哪个 JDK 执行 Maven 编译会影响能否顺利构建。下表是实践中比较稳的组合:
| 组件 | 最低 | 推荐 | 说明 |
|---|---|---|---|
| JDK | 8 | 11 或 17 | 源码目标 1.8;JDK 17 编译需注意 --release 兼容 |
| Maven | 3.6.x | 3.8.x / 3.9.x | 3.9 对 HTTPS 仓库更严格,注意 settings.xml |
| 操作系统 | 任意 | Linux / macOS / Windows | 换行符与路径分隔符会影响 Checkstyle 结果 |
| 内存 | 1 GB | 4 GB+ | TestBuilder 打包与 javadoc 聚合比较吃内存 |
| 可选 | gpg | gpg | 仅发布签名需要,日常构建不需要 |
21.2.2 第一次构建
在仓库根目录执行:
# 完整构建(含测试),首次会下载大量依赖
mvn clean install
# 构建但跳过测试(推荐在只想拿到 jar 时使用)
mvn clean install -DskipTests
# 只构建并安装 jts-core 一个模块
mvn clean install -pl modules/core
# 构建 core 及其依赖模块
mvn clean install -pl modules/core -am
构建成功后,产物分布如下:
jts/
├── build-tools/target/ # jts-build-tools-1.21.0-SNAPSHOT.jar
│ # 内含 checkstyle.xml / pmd-ruleset.xml / header.txt
└── modules/
├── core/target/
│ ├── jts-core-1.21.0-SNAPSHOT.jar
│ ├── jts-core-1.21.0-SNAPSHOT-tests.jar # test-jar,供 io/lab 复用测试基类
│ └── apidocs/ # javadoc 产物目录
├── io/common/target/jts-io-common-1.21.0-SNAPSHOT.jar
├── app/target/JTSTestBuilder.jar # 带依赖的可执行 jar(assembly 插件)
├── tests/target/JTSTestRunner.jar # XML 测试运行器可执行 jar
└── lab/target/jts-lab-1.21.0-SNAPSHOT.jar
注意 jts-core 的 packaging 是 bundle(OSGi 打包),不是普通 jar。这意味着 maven-bundle-plugin 会接管打包,生成 Bundle-SymbolicName、Export-Package 等清单条目;同时 maven-jar-plugin 额外执行 test-jar,把测试类和测试基类打进 -tests.jar。modules/io/common 与 modules/lab 都依赖这个 test-jar,所以单独构建 io 或 lab 前必须先装好 core。
21.2.3 JDK 版本相关的坑
源码里写着 maven.compiler.source=1.8,但如果你用 JDK 17 的 javac 编译 Java 8 目标,会遇到两类问题:
-source 8 -target 8的废弃警告,编译不失败但输出大量 warning。- 语言级别不一致导致“本地能编译、CI 报错”:IDE 若按 17 的语言级别检查,你顺手用了
List.of()、var、String.isBlank(),本地一路绿灯,CI 侧按 1.8 目标编译就会失败。
稳妥做法是在 Maven 中显式指定 --release 8 语义,或干脆用 JDK 11 跑构建:它同时支持 --release 8,又不会引入 Java 9+ 模块系统带来的额外麻烦。
# 查看当前 Maven 使用的 JDK
mvn -version
# 临时切换 JDK(Linux/macOS)
export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
# Windows PowerShell
$env:JAVA_HOME = "C:\Program Files\Java\jdk-11"
21.2.4 profile 与模块开关
根 pom.xml 与 modules/pom.xml 用 profile 控制哪些模块参与构建,这一点很容易踩坑:默认构建的模块集合由 all profile 决定。
<!-- modules/pom.xml 片段 -->
<modules>
<module>core</module>
<module>io</module>
</modules>
<profiles>
<profile>
<id>all</id>
<activation>
<property>
<name>!release</name>
</property>
</activation>
<modules>
<module>example</module>
<module>tests</module>
<module>app</module>
<module>lab</module>
</modules>
</profile>
</profiles>
含义是:只要没有定义 release 属性,all profile 就自动激活,因此默认的 mvn clean install 会构建全部模块;而发布时执行 mvn install -Drelease,all 被关掉,只保留 core 与 io,避免把 GUI 与实验代码发到中央仓库。
| 命令 | 激活 profile | 构建模块 |
|---|---|---|
mvn clean install |
all(自动) |
build-tools、core、io、example、tests、app、lab |
mvn install -Drelease |
release |
build-tools、core、io(含签名与 nexus-staging) |
mvn install -Dall=true |
all 显式 |
同默认,但语义明确 |
mvn install -Poracle |
oracle |
额外构建 jts-io-ora |
mvn install -Parcsde |
arcsde |
额外构建 jts-io-sde(需专有依赖) |
jts-io-ora 与 jts-io-sde 依赖 Oracle/SDE 的专有 jar,仓库里不带这些依赖,所以默认不构建。想构建必须先自己把驱动装进本地仓库,否则会以 Could not resolve dependencies 失败——这不是环境坏了,是设计如此。
21.2.5 IDE 导入要点
IDEA 直接 File → Open 选根目录的 pom.xml,等 Maven 同步完成即可。Eclipse 的步骤官方写在 DEVELOPING.md 里,关键点有三个:
- 用
File → Import → Maven → Existing Maven Project,根目录选jts顶层目录。 - 初次构建会提示
checkstyle:check的 lifecycle mapping 警告,用 quickfix 选 ignore;不要安装 maven-checkstyle-plugin 连接器,官方明确说它会装失败。 - 想看到实时 Checkstyle/PMD 报告,需装 Eclipse-CS 与 eclipse-pmd,并把规则指向
build-tools/src/main/resources/jts/下的checkstyle.xml、pmd-ruleset.xml、header.txt。
Run Configuration 的推荐配置(来自 DEVELOPING.md):
| 用途 | 主类 | 参数 |
|---|---|---|
| 跑 XML 测试 | org.locationtech.jtstest.testrunner.JTSTestRunnerCmd |
validate general,工作目录设为 modules/tests/src/test/resources/testxml |
| 打开 TestBuilder | org.locationtech.jtstest.testbuilder.JTSTestBuilder |
VM 参数 -Xmx1000M;可加 -geomfunc <类名> |
| 命令行执行操作 | org.locationtech.jtstest.cmd.JTSOpCmd |
见 doc/JTSOp.md,VM 参数 -Xmx10000M |
21.2.6 构建失败的常见症状
| 症状 | 可能原因 | 处置 |
|---|---|---|
Could not resolve jdom2 |
网络/私服缺少依赖 | 检查 ~/.m2/settings.xml 镜像配置 |
checkstyle:check failed |
新文件缺 header 或用了 import * | 补 license header,改用显式 import |
pmd:check failed with priority 2 |
新增代码触发规则 | 按报告改写法,不要盲目加 suppress |
Failed to execute goal javadoc |
Javadoc 有语法错误或链接失效 | mvn compile javadoc:javadoc -pl modules/core 单独跑一次看具体行 |
| No tests were executed | -DskipTests 或 surefire 未识别命名 |
检查测试类是否以 Test 结尾且继承自 TestCase |
21.3 仓库目录结构详解
21.3.1 顶层布局
jts/ # 仓库根,Maven 聚合工程(packaging=pom)
├── pom.xml # 父 POM:版本号、编译器、JUnit、PMD、Checkstyle、javadoc
├── build-tools/ # 质量规则的载体模块(jar),被 PMD/Checkstyle 插件依赖
│ └── src/main/resources/jts/
│ ├── checkstyle.xml # Checkstyle 规则(Header、AvoidStarImport 等)
│ ├── header.txt # 每个 Java 文件必须匹配的许可证头
│ ├── pmd-ruleset.xml # PMD 规则集
│ └── suppressions.xml # 规则豁免清单
├── modules/ # 所有子模块
│ ├── pom.xml # 子模块聚合 POM,定义 core/io 与 all profile
│ ├── core/ # jts-core:几何引擎本体
│ ├── io/ # io 聚合:common / ora / sde
│ ├── example/ # jts-example:示例程序,不发布
│ ├── tests/ # jts-tests:XML 测试运行器 + 测试资源
│ ├── app/ # jts-app:TestBuilder GUI 与 JtsOp CLI
│ └── lab/ # jts-lab:实验性算法,不发布到中央仓库
├── doc/ # 文档:版本历史、工具说明、样例 header、PDF 规范
├── bin/ # 启动脚本:jtsop.sh / testbuilder.sh / testrunner.sh
├── DEVELOPING.md # 开发指南(构建、QA、IDE 配置)
├── CONTRIBUTING.md # 贡献指南(ECA、PR、label)
├── RELEASING.md # 发布手册(仅维护者用)
└── USING.md # 使用说明
21.3.2 模块依赖关系
模块之间是严格的分层依赖,构建顺序也由它决定(Maven reactor 自动排序):
┌──────────────────────────┐
│ build-tools │ 只提供资源(规则文件)
└────────────┬─────────────┘
│ (插件依赖,不参与代码依赖)
▼
┌──────────────────────────────────────────────────────────┐
│ jts-core │
│ geom / algorithm / operation / index / io / simplify … │
└───────┬──────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ jts-io-common │ │ jts-tests │
│ geojson / twkb │ │ XML 运行器 + 测试资源 │
│ WKT/WKB 扩展 │ │ 依赖 jdom2 │
└────────┬─────────┘ └──────────┬───────────┘
│ │
│ ┌──────────────────────┤
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ jts-app │ │ jts-lab │
│ TestBuilder + CLI │ │ 实验算法 + 自定义 │
│ 依赖 core/io/tests │ │ 函数(jtslab) │
└────────────────────┘ └──────────────────┘
jts-example ──> 仅依赖 jts-core,独立可跑,用于教学与自检
这张图有三个实用推论:
- 改
jts-core必须重装后才能验证app、tests、lab。用mvn install -pl modules/app -am让 Maven 连带构建上游模块。 jts-lab依赖 core 的 test-jar(<type>test-jar</type>),所以它能在测试里复用GeometryTestCase;这也是为什么发布时 test-jar 需要一并安装。jts-tests与jts-app互相独立但都依赖jts-core,jts-app额外依赖jts-tests(因为 TestBuilder 要能加载并运行 XML 测试)。
21.3.3 各模块职责速查
| 模块 | artifactId | 是否发布 | 主要职责 | 关键包 |
|---|---|---|---|---|
| build-tools | jts-build-tools |
否 | 承载 QA 规则资源 | jts/*.xml |
| core | jts-core |
是 | 几何模型与全部算法 | org.locationtech.jts.* |
| io/common | jts-io-common |
是 | GeoJSON、TWKB、WKT/WKB 扩展 | org.locationtech.jts.io.* |
| io/ora | jts-io-ora |
否(需 profile) | Oracle 空间格式读写 | org.locationtech.jts.io.oracle |
| io/sde | jts-io-sde |
否(需 profile) | ArcSDE 格式读写 | org.locationtech.jts.io.sde |
| example | jts-example |
否 | 可运行示例集 | org.locationtech.jtsexample.* |
| tests | jts-tests |
否 | XML 测试解析与执行 | org.locationtech.jtstest.testrunner |
| app | jts-app |
否 | TestBuilder GUI 与 JtsOp CLI | org.locationtech.jtstest.* |
| lab | jts-lab |
否 | 实验性算法与自定义函数样例 | org.locationtech.jtslab.* |
21.4 jts-core 的包地图
21.4.1 顶层包一览
modules/core/src/main/java/org/locationtech/jts 下有 20 个直接子包,52 个 package-info.java(几乎每个包都有说明文档)。按职责分成四层:
┌───────────────────────────────────────────────────────────────┐
│ 模型层(数据结构) │
│ geom 几何类型、Coordinate、GeometryFactory │
│ geom.impl CoordinateSequence 的各种实现 │
│ geom.util GeometryTransformer / GeometryFixer 等工具 │
│ geom.prep PreparedGeometry(预编译加速谓词) │
│ precision PrecisionModel(精度模型) │
├───────────────────────────────────────────────────────────────┤
│ 算法层(几何运算) │
│ algorithm 距离、凸包、MBC、角点、内切圆等 │
│ operation 谓词、叠加、缓冲、化简、合并、有效性子包 │
│ simplify DP / TP / VW 三类简化器 │
│ densify 加密 │
│ dissolve 线溶解(LineDissolver) │
│ coverage Coverage 校验与修复(1.20 起) │
│ triangulate Delaunay / Voronoi / 三角剖分 │
├───────────────────────────────────────────────────────────────┤
│ 索引与支撑层 │
│ index STRtree / Quadtree / KdTree / intervalrtree │
│ noding 节点化(MC / SC / Snap 等 noder) │
│ edgegraph 半边图 │
│ planargraph 平面图 │
│ math DD 双精度、向量、角度工具 │
├───────────────────────────────────────────────────────────────┤
│ IO 与杂项 │
│ io WKT/WKB 读写、Ordinate 枚举 │
│ linearref 线性参考(LengthIndexedLine / LocationIndexed)│
│ util Assert / Stopwatch / PriorityQueue 等 │
│ shape 星形、正弦星等测试形状生成器 │
│ awt Swing 相关的最小支撑 │
└───────────────────────────────────────────────────────────────┘
21.4.2 用 grep 定位实现的三条命令
读源码最费时间的不是读,而是“找到该读哪个文件”。三条模式能覆盖八成场景:
# 1) 按类名找文件(类名一般与文件名一致)
find modules/core/src/main/java -name "OverlayNG*.java"
# 2) 找某个公开方法在哪些类里出现(例如所有返回 Coverage 校验结果的地方)
grep -rn "public static Geometry\[\] validate" modules/core/src/main/java
# 3) 从高层 API 顺藤摸瓜:先看 Geometry 方法内部委托给谁
grep -n "GeometryOverlay" modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java
第 3 条尤其重要。JTS 的高层 Geometry 方法基本都是一行委托,真正的实现藏在内部类里。例如:
// modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java
public Geometry intersection(Geometry other) {
return GeometryOverlay.intersection(this, other);
}
public Geometry union(Geometry other) {
return GeometryOverlay.union(this, other);
}
public Geometry union() {
return GeometryOverlay.union(this);
}
而 GeometryOverlay 是一个包私有(class,不是 public class)的策略类,默认走旧引擎,只有系统属性 jts.overlay=ng 时才切换:
// modules/core/src/main/java/org/locationtech/jts/geom/GeometryOverlay.java
class GeometryOverlay {
public static String OVERLAY_PROPERTY_NAME = "jts.overlay";
public static String OVERLAY_PROPERTY_VALUE_NG = "ng";
public static boolean OVERLAY_NG_DEFAULT = false;
private static boolean isOverlayNG = OVERLAY_NG_DEFAULT;
static {
setOverlayImpl(System.getProperty(OVERLAY_PROPERTY_NAME));
}
private static Geometry overlay(Geometry a, Geometry b, int opCode) {
if (isOverlayNG) {
return OverlayNGRobust.overlay(a, b, opCode);
} else {
return SnapIfNeededOverlayOp.overlayOp(a, b, opCode);
}
}
}
这段代码解释了一个高频困惑:为什么线上 geom.union(other) 的结果和 TestBuilder 里 Overlay.union 的结果不完全一样? 因为 TestBuilder 的 OverlayNG.union 直接调用 OverlayNG.overlay(a, b, UNION),而 Geometry.union 默认走的还是旧引擎加 snap 回退。想让业务代码统一走新引擎,加启动参数:
java -Djts.overlay=ng -cp jts-core.jar:app.jar com.example.Main
同理,关系谓词有 GeometryRelate 与系统属性 jts.relate(默认 old,设为 ng 启用 RelateNG)。这两个开关都是进程级全局状态,在容器化部署里通过环境变量 JAVA_TOOL_OPTIONS 注入最省事。
21.4.3 命名约定与包的可见性
读源码时会注意到几个反复出现的模式,理解了它们就能猜到实现位置:
| 模式 | 含义 | 例子 |
|---|---|---|
XxxOp |
一次性的操作类,通常有静态入口 | IsValidOp、BufferOp、OverlayOp |
XxxNG |
“Next Generation”新引擎实现 | OverlayNG、RelateNG |
Xxxer |
对几何做结构性变换的处理器 | GeometryFixer、LineDissolver |
XxxBuilder |
构造中间结构后给 build() |
PolygonBuilder、EdgeRing 的构造器 |
XxxIndex |
空间索引结构 | STRtree、Quadtree |
XxxFunctions |
TestBuilder 的静态函数集合(只在 app/lab 模块) | OverlayNGFunctions |
包私有类的使用也很克制但有规律:凡是“内部策略选择”“需要防止外部依赖”的实现,都会写成包私有,例如 GeometryOverlay、GeometryRelate。你改动这些类时不需要考虑二进制兼容,因为它们不在公开 API 里;反过来,改动 Geometry、Coordinate、GeometryFactory 的公开签名则要考虑兼容性。
21.5 JUnit 单测体系
21.5.1 双轨制测试的分工
JTS 的测试分两条轨道,理解分工是写测试的前提:
| 维度 | JUnit 单测 | XML 声明式测试 |
|---|---|---|
| 位置 | modules/*/src/test/java |
modules/tests/src/test/resources/testxml |
| 语言 | Java | XML(WKT 字面量) |
| 擅长 | API 边界、内部数据结构的白盒验证 | 几何语义的黑盒表达,跨语言可复用 |
| 可读性 | 需要懂 Java | 只要懂 WKT 和操作名 |
| 能否被 GEOS 等移植复用 | 不能 | 能 |
| 数量 | core 约 388 个测试类 | 129 个 XML 文件 |
| 运行入口 | mvn test -pl modules/core |
mvn test -pl modules/tests 或 testrunner.sh |
官方 DEVELOPING.md 的原话是:JTS 目标是把单元测试覆盖率做到 100%,单测用于验证 API、内部数据结构与辅助算法,能做到更丰富的错误检测与报告,但可读性与可移植性不如 XML 测试;而 XML 测试适合表达“库的基本几何语义”,能被 TestBuilder 加载,也能被 GEOS 等移植项目复用。
实践建议:修 bug 先写 JUnit 用例复现,再补一个 XML 用例固化语义。前者给你调试断点,后者让上游维护者一眼看懂“这个操作应该等于什么”。
21.5.2 最小可运行的单测
JTS 用的是 JUnit 4(junit:junit:4.13.1),但大量测试类仍然写成 extends TestCase 的 JUnit 3 风格——GeometryTestCase 本身就继承 junit.framework.TestCase。两种风格混用是允许的,surefire 都能识别:
package org.locationtech.jts.operation.distance;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import junit.framework.TestCase;
/**
* 演示 JTS 风格的 JUnit 3 单测:继承 TestCase,方法名以 test 开头。
*/
public class DistanceSmokeTest extends TestCase {
public DistanceSmokeTest(String name) {
super(name);
}
public void testPointToLineDistance() throws Exception {
WKTReader reader = new WKTReader();
Geometry pt = reader.read("POINT (0 0)");
Geometry line = reader.read("LINESTRING (1 -1, 1 1)");
double dist = pt.distance(line);
// 点到竖直线 x=1 的距离应为 1
assertEquals(1.0, dist, 1e-9);
}
public void testNearestPoints() throws Exception {
WKTReader reader = new WKTReader();
Geometry a = reader.read("POINT (0 0)");
Geometry b = reader.read("LINESTRING (2 0, 4 0)");
Coordinate[] nearest = DistanceOp.nearestPoints(a, b);
assertEquals(2.0, nearest[0].x, 1e-9);
assertEquals(0.0, nearest[0].y, 1e-9);
assertEquals(2.0, nearest[1].x, 1e-9);
}
}
DistanceOp 位于 org.locationtech.jts.operation.distance,公开静态方法 nearestPoints(Geometry, Geometry) 返回长度为 2 的 Coordinate[]。运行方式:
mvn test -pl modules/core -Dtest=DistanceSmokeTest
21.5.3 GeometryTestCase:几何断言的正确用法
手写 assertEquals("LINESTRING(...)", geom.toString()) 是很脆的写法:坐标顺序、环的起点、Multi 成员的顺序都可能导致字符串不相等而几何等价。JTS 提供 test.jts.GeometryTestCase 作为基类,封装了各类几何断言:
package org.locationtech.jts.geom.util;
import org.locationtech.jts.geom.Geometry;
import test.jts.GeometryTestCase;
/**
* 继承 GeometryTestCase,直接用 checkEqual / checkValid 等断言。
* 注意:本类属于 core 的测试源码,包名需与被测类一致才能访问包私有 API。
*/
public class FixerDemoTest extends GeometryTestCase {
public FixerDemoTest(String name) {
super(name);
}
public void testFixSelfIntersection() {
Geometry input = read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");
Geometry fixed = GeometryFixer.fix(input);
// 修复后必须有效
checkValid(fixed);
// 结果可能被拆成 MultiPolygon,按面积核对不丢面积
assertEquals(input.getArea(), fixed.getArea(), 1e-9);
}
public void testFixEmptyPoint() {
Geometry input = read("POINT (0 NaN)");
Geometry fixed = GeometryFixer.fix(input);
checkEqual(read("POINT EMPTY"), fixed);
}
}
GeometryTestCase 提供的能力(源码 modules/core/src/test/java/test/jts/GeometryTestCase.java):
| 方法 | 作用 | 注意点 |
|---|---|---|
read(String wkt) |
用内部 WKTReader 解析 WKT |
受 geomFactory 影响,默认 CoordinateArraySequenceFactory |
checkEqual(Geometry, Geometry) |
归一化后 equalsExact 比较 |
会先 norm(),忽略环起点与成员顺序 |
checkEqualExact(...) |
不归一化,逐坐标严格相等 | 用于验证坐标顺序本身 |
checkEqual(expected, actual, tolerance) |
带容差的归一化比较 | 浮点误差场景必用 |
checkEqualXYZ / checkEqualXYZM |
比较三维/四维坐标 | 验证 Z/M 是否保留 |
checkValid(Geometry) |
断言 isValid() |
修复类算法必加 |
checkEqual(Geometry[], Geometry[]) |
数组逐元素比较 | Coverage 校验结果对照 |
它的构造函数有一个坑:GeometryTestCase(String name) 固定使用 CoordinateArraySequenceFactory.instance()(源码里写死),而生产代码可以通过 GeometryFactory(CoordinateSequenceFactory) 换成 PackedCoordinateSequenceFactory。类里提供了带 CoordinateSequenceFactory 参数的重载 GeometryTestCase(String name, CoordinateSequenceFactory),要复现生产配置就用它,否则测试用的序列实现和生产可能不一致。
21.5.4 测试类的目录与包名约定
JTS 的测试类有两种放法,区别很重要:
- 放在被测类同包:
modules/core/src/test/java/org/locationtech/jts/operation/valid/IsValidOpTest.java。同包意味着能访问包私有成员,适合白盒测试内部状态。 - 放在
test.jts包下:modules/core/src/test/java/test/jts/...。用于跨包的公共测试基础设施(GeometryTestCase就在这里),也用于性能冒烟测试。
命名上,测试类以 Test 结尾,方法以 test 开头。surefire 有三类默认包含模式:**/Test*.java、**/*Test.java、**/*Tests.java(另有 **/*TestCase.java),所以 XxxTests 这种复数写法也能被识别——但混用两种命名风格会让 -Dtest= 的过滤条件变复杂,JTS 仓库统一用 *Test,跟随即可。
21.5.5 覆盖率视角的测试清单
给一个新算法类写测试时,按下面的清单过一遍,基本能覆盖维护者会问的问题:
┌────────────────────────────────────────────────────────────┐
│ 新算法测试清单 │
├────────────────────────────────────────────────────────────┤
│ 1. 正常输入:典型几何 + 典型参数,断言精确结果 │
│ 2. 空几何:POINT EMPTY / LINESTRING EMPTY / GEOMETRYCOLLECTION EMPTY │
│ 3. 退化输入:单点线、零面积面、重复坐标 │
│ 4. 边界参数:0、负值、极大值、NaN/Infinity │
│ 5. 非简单几何:自相交、多环、洞、嵌套洞 │
│ 6. 混合维度:点 + 线 + 面的 GeometryCollection │
│ 7. Z/M 值:是否保留、是否被丢弃 │
│ 8. SRID:结果是否继承 │
│ 9. 幂等性:f(f(g)) == f(g)(对修复类算法) │
│ 10. 不修改输入:调用前后输入几何的 WKT 必须一致 │
└────────────────────────────────────────────────────────────┘
第 10 条经常被忽略。JTS 的公开方法在多数情况下不修改输入,但内部为了性能会复用对象。如果你写的新算法直接改 Coordinate 数组,很容易踩到“调用方传入的几何被悄悄改了”的坑,导致上游结果漂移。
21.6 XML 声明式测试
21.6.1 XML 测试文件的结构
一个 XML 测试文件以 <run> 为根,内含若干 <case>,每个 <case> 有一条或多条 <test>:
<run>
<case>
<desc>L - single segment</desc>
<a>LINESTRING (10 10, 100 10)</a>
<test><op name="densify" arg1="A" arg2="10.0">
LINESTRING (10 10, 20 10, 30 10, 40 10, 50 10, 60 10, 70 10, 80 10, 90 10, 100 10)
</op></test>
</case>
<case>
<desc>L - single segment with non-integer distance, result is evenly subdivided</desc>
<a>LINESTRING (0 0, 0 6)</a>
<test><op name="densify" arg1="A" arg2="2.999999">
LINESTRING (0 0, 0 2, 0 4, 0 6)
</op></test>
</case>
</run>
各元素含义:
| 元素/属性 | 含义 | 备注 |
|---|---|---|
<run> |
根元素 | 一个文件一次运行 |
<case> |
一组测试 | 共享 <a> 与 <b> 两个输入几何 |
<desc> |
用例描述 | 出错时会打印,写清意图 |
<a> / <b> |
输入几何(WKT 或 WKB) | <b> 可省略(一元操作) |
<test> |
一条断言 | 内容为期望结果的 WKT,或布尔/数值字面量 |
<op> |
操作调用 | name 是函数名,arg1 选择作用对象,arg2 起才是操作参数 |
arg1="A" |
选择被操作的几何:A 或 B | 只有 A/B 两个合法值,缺省为 A |
21.6.2 参数引用的规则
这是最容易误解的一处:arg1 不是“第一个函数参数”,而是“对哪个几何执行操作”。看 Test.getActualResult() 的实现就清楚了:
// modules/tests/src/main/java/org/locationtech/jtstest/testrunner/Test.java
public Result getActualResult() throws Exception {
if (isRun) return actualResult;
isRun = true;
targetGeometry = geometryIndex.equalsIgnoreCase("A")
? testCase.getGeometryA()
: testCase.getGeometryB();
operationArgs = convertArgs(arguments);
GeometryOperation op = getGeometryOperation();
actualResult = op.invoke(operation, targetGeometry, operationArgs);
return actualResult;
}
也就是说:
arg1="A"表示“把<a>的几何作为被操作对象(this)”;写"B"则用<b>的几何。arg2、arg3才是真正的操作参数,解析时按顺序放进arguments列表。- 参数类型由
ArgumentConverter按目标方法的形参类型转换,支持boolean、int、double、String四类;其他类型会直接抛IllegalArgumentException。 - 若
arg2缺省,则不传参数;若<test>的内容为空,则不校验期望值(只执行不断言)。
所以 <op name="densify" arg1="A" arg2="10.0"> 的真实含义是:对几何 A 调用 densify,唯一参数是 10.0。二元操作如 intersection 写成 <op name="intersection" arg1="A" arg2="B">,表示“A 与 B 求交”。
21.6.3 TestRunner 的三种用法
# 1) 官方推荐:在 testxml 目录下,把子目录名当参数
cd modules/tests/src/test/resources/testxml
java -jar ../../../../target/JTSTestRunner.jar validate general
# 2) 显式指定文件或目录列表
java -jar modules/tests/target/JTSTestRunner.jar \
-files modules/tests/src/test/resources/testxml/validate/TestRelateAA.xml
# 3) 只跑某个索引的用例,并输出详细结果
java -jar modules/tests/target/JTSTestRunner.jar -verbose -testIndex 0 general
常用命令行选项(源码 JTSTestRunnerCmd.readOptions):
| 选项 | 作用 |
|---|---|
-files <fileOrDir>... |
指定 XML 文件或目录(可多个) |
-geomfunc <class>... |
额外注册静态函数类(可覆盖同名函数) |
-geomop <classname> |
指定自定义 GeometryOperation 实现 |
-testIndex <n> |
只运行指定索引的测试用例 |
-verbose |
输出成功用例的详细信息 |
-op / -afile / -bfile / -arg1 |
单次运行某个操作,A/B 来自文件 |
-properties <file> |
从 properties 读取 TestFiles 列表 |
21.6.4 把 XML 测试接进 Maven
modules/tests 里有一个桥接类,让 XML 测试变成标准的 JUnit 测试,从而被 CI 覆盖:
// modules/tests/src/test/java/org/locationtech/jtstest/CoreGeometryXMLTest.java(节选)
public class CoreGeometryXMLTest extends TestCase {
public CoreGeometryXMLTest(String name) {
super(name);
}
public void testUnit() {
testFiles("src/test/resources/testxml/general",
"src/test/resources/testxml/validate");
}
private void testFiles(String... directoryName) {
TestEngine engine = new TestEngine();
List<File> testFiles = new ArrayList<File>();
for (String dirName : directoryName) {
testFiles.addAll(filenames(new File(dirName)));
}
engine.setTestFiles(testFiles);
engine.run();
SimpleReportWriter reportWriter = new SimpleReportWriter(false);
System.out.println(reportWriter.writeReport(engine));
boolean failures = engine.getParseExceptionCount()
+ engine.getFailedCount()
+ engine.getExceptionCount() > 0;
assertEquals(failures, false);
}
}
注意它默认只跑 general 与 validate 两个目录,failure、robust、misc 是注释掉的。这意味着 mvn test 绿灯不代表所有 XML 用例都过了;想跑 robust 目录得手动加进去或用 TestRunner 单独跑。
21.6.5 新增一个 XML 用例的完整流程
假设你给 GeometryFunctions 加了一个函数 snapToGrid,准备补 XML 用例:
- 决定归属目录:常规几何语义放
general/,校验与修复放validate/,鲁棒性(可能触发异常)放robust/。 - 新建
TestSnapToGrid.xml,写一个最简单的正向用例,确认能被解析。 - 本地跑
testrunner.sh -files <新文件>,看报告里的期望/实际差异。 - 把文件加进
CoreGeometryXMLTest.testFiles(...)的目录参数(如果新建了目录)。 - 跑
mvn test -pl modules/tests确认集成路径也过了。
一个可用的正向用例模板:
<run>
<case>
<desc>snapToGrid: 坐标为 0.1 的倍数时吸附到网格</desc>
<a>POINT (1.02 2.04)</a>
<test><op name="snapToGrid" arg1="A" arg2="0.5">POINT (1.0 2.0)</op></test>
</case>
</run>
21.7 构建质量门禁:PMD 与 Checkstyle
21.7.1 门禁的接入方式
JTS 把 PMD 与 Checkstyle 都绑定在 Maven 生命周期上,所以 mvn install 顺手就把它们跑了:
<!-- 根 pom.xml:Checkstyle 绑在 validate 阶段 -->
<plugin>
<artifactId>maven-checkstyle-plugin</artifactId>
<configuration>
<logViolationsToConsole>true</logViolationsToConsole>
<configLocation>jts/checkstyle.xml</configLocation>
<headerLocation>jts/header.txt</headerLocation>
<suppressionsLocation>jts/suppressions.xml</suppressionsLocation>
<suppressionsFileExpression>checkstyle.suppressions.file</suppressionsFileExpression>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
这两段配置里有个细节值得注意:插件的规则文件不是写在本地路径,而是通过一个依赖引入的——
<dependencies>
<!-- 共享的 PMD 规则集 -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>build-tools</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 指定较新的 PMD/Checkstyle 版本 -->
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-core</artifactId>
<version>${pmd.version}</version>
</dependency>
</dependencies>
于是 <configLocation>jts/checkstyle.xml</configLocation> 实际解析为 build-tools jar 内的 jts/checkstyle.xml。这也是 build-tools 必须第一个构建的原因:如果只 mvn install -pl modules/core 而本地仓库里没有 build-tools,插件解析配置就会失败。
21.7.2 Checkstyle 实际检查什么
规则文件里打开的项目比想象中少,但每一条都是硬性失败(severity=error):
| 规则 | 含义 | 违反后果 |
|---|---|---|
Header |
文件头必须匹配 jts/header.txt |
构建直接失败 |
AvoidStarImport |
禁止 import xxx.* |
构建直接失败 |
SuppressionFilter |
读取 jts/suppressions.xml 的豁免 |
未豁免的违规不放过 |
Header 规则配置了 ignoreLines=2,意思是第 2 行(版权年份那行)被忽略。这就是为什么你可以写 Copyright (c) 2024 你的名字 而不必和模板完全一致:
/*
* Copyright (c) 2024 Your Name.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
*/
注意 checkstyle.xml 里大量规则是被注释掉的(JavadocMethod、JavadocMissingWhitespaceAfterAsterisk 等,注释里标了历史扫出的违规数量,如 <!-- 502 violations -->)。不要自作主张去打开它们——一开全项目几百处违规,构建立刻红一片。要加规则先跟维护者沟通。
21.7.3 PMD 的失败阈值
PMD 配置里关键的一行是:
<failurePriority>2</failurePriority>
含义是优先级 1(High)与 2(Medium High)的违规会导致构建失败,优先级 3 及以下只报告不阻断。当前规则集里实际启用的高优先级规则很少:
<!-- Priority: Medium High (2) -->
<rule ref="category/java/errorprone.xml/AvoidBranchingStatementAsLastInLoop" />
<rule ref="category/java/bestpractices.xml/PrimitiveWrapperInstantiation" />
也就是说,只要你不写“循环体最后一句是 break/continue/return”和 new Integer(1) 这种包装类构造,PMD 基本不会拦你。但注意 <printFailingErrors>true</printFailingErrors> 会把全部违规打到控制台,别被大段输出吓到,先看 [ERROR] 而不是 [WARNING]。
跳过质量检查的命令(官方 DEVELOPING.md 给出):
mvn verify -Dpmd.skip=true -Dcheckstyle.skip=true
跳过只用于本地快速验证编译,PR 之前必须去掉参数跑一次完整的 mvn verify,否则 CI 会替你发现。
21.7.4 查看报告
# 生成站点报告(含 PMD/Checkstyle/JXR 源码交叉引用)
mvn site:site
# 报告入口:modules/core/target/site/index.html
# 更快的办法:单独跑两个检查
mvn checkstyle:check -pl modules/core
mvn pmd:check -pl modules/core
21.8 新增算法类的编码规范
21.8.1 文件骨架
JTS 对新增类的结构要求相当统一,照着下面的模板写基本不会被打回:
/*
* Copyright (c) 2024 Your Name.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
* The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
* and the Eclipse Distribution License is available at
*
* http://www.eclipse.org/org/documents/edl-v10.php.
*/
package org.locationtech.jts.operation.buffer;
import org.locationtech.jts.geom.Geometry;
/**
* Computes a simple metric over a polygonal geometry.
* <p>
* The implementation visits each element in turn and accumulates
* the requested quantity. Input is never modified.
* <p>
* Example of use:
* <pre>
* double a = PolygonMetric.areaOfPolygons(geom);
* </pre>
*
* @author Your Name
* @see Geometry#getArea()
*/
public class PolygonMetric {
/**
* Computes the total area of all polygonal elements in a geometry.
*
* @param geom the geometry to process (may be null or empty)
* @return the total area, or 0.0 for null/empty input
*/
public static double areaOfPolygons(Geometry geom) {
if (geom == null || geom.isEmpty()) {
return 0.0;
}
double total = 0.0;
for (int i = 0; i < geom.getNumGeometries(); i++) {
Geometry g = geom.getGeometryN(i);
if (g instanceof Polygon) {
total += g.getArea();
}
else if (g instanceof GeometryCollection) {
total += areaOfPolygons(g);
}
}
return total;
}
private PolygonMetric() {
// utility class - not instantiable
}
}
这段代码里有几处“看起来无所谓、实际会被要求改”的地方:
- 私有构造器:纯静态工具类不能有公开构造器。JTS 里不少老类没有照做(历史包袱),但新类会被要求。
- Javadoc 覆盖率:类必须有 Javadoc,公开方法必须有
@param、@return,可抛异常要有@throws。 - 不改输入:方法注释里明确写
Input is never modified,并在实现里只读。 - 空几何早退:
geom.isEmpty()的分支必须显式处理,否则组合运算里容易出现NaN。
21.8.2 package-info.java 与包级文档
新增一个包时,必须补 package-info.java。它不是可选项:JTS core 里有 52 个 package-info.java,几乎每个包都有。格式如下:
/*
* Copyright (c) 2024 Your Name.
* ... 标准 header ...
*/
/**
* Provides classes for computing buffer curves and offset curves.
* <p>
* The main entry point is {@link org.locationtech.jts.operation.buffer.BufferOp}.
*/
package org.locationtech.jts.operation.buffer;
注意 package-info.java 里只有 package 声明 + Javadoc,没有别的语句。Checkstyle 的 Header 规则对它同样生效。
21.8.3 命名与分层约定
| 场景 | 约定 | 反例 |
|---|---|---|
| 一次性操作 | XxxOp,提供静态入口 |
XxxCalculator |
| 新引擎实现 | XxxNG |
XxxNew |
| 遍历几何 | XxxFilter / XxxVisitor(实现 GeometryFilter/GeometryVisitor) |
XxxWalker |
| 结果构建 | XxxBuilder |
XxxCreator |
| 函数集合 | XxxFunctions(只在 app/lab) |
XxxUtil |
| 包级内部策略 | 包私有 class,不加 public |
把内部类做成 public API |
| 包名 | 全小写,语义分层如 operation.overlayng |
operation.overlayNG |
一条经验:不确定该不该 public 时,先写成包私有。JTS 对公开 API 的兼容性要求很高(有 ABI 兼容性跟踪),新加的公开类一旦发布就基本不能改签名;包私有则随时可动。
21.8.4 完整的可运行示例:带 main 自检的工具类
下面这个类演示“新增算法 + 自带 main 自检”的写法,可以直接放进 jts-core 后编译运行:
package org.locationtech.jts.operation.polygonize;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.io.WKTReader;
/**
* Counts the number of holes (interior rings) in a polygonal geometry.
* <p>
* Demonstration of the recommended shape of a small JTS utility class:
* static entry point, null/empty guard, no mutation of input,
* and a {@code main} method usable as a smoke test.
*/
public class HoleCounterDemo {
/**
* Counts interior rings of all polygons in a geometry.
*
* @param geom the geometry to inspect
* @return the total number of holes
*/
public static int countHoles(Geometry geom) {
if (geom == null || geom.isEmpty()) return 0;
int count = 0;
for (int i = 0; i < geom.getNumGeometries(); i++) {
Geometry g = geom.getGeometryN(i);
if (g instanceof Polygon) {
count += ((Polygon) g).getNumInteriorRing();
}
else if (g instanceof GeometryCollection) {
count += countHoles(g);
}
}
return count;
}
private HoleCounterDemo() {
}
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader(new GeometryFactory());
Geometry poly = reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))");
System.out.println("holes = " + countHoles(poly));
}
}
编译与运行(假设已在本地安装 jts-core):
# Linux / macOS(classpath 分隔符为冒号)
javac -cp modules/core/target/jts-core-1.21.0-SNAPSHOT.jar -d /tmp/out \
modules/core/src/main/java/org/locationtech/jts/operation/polygonize/HoleCounterDemo.java
java -cp modules/core/target/jts-core-1.21.0-SNAPSHOT.jar:/tmp/out \
org.locationtech.jts.operation.polygonize.HoleCounterDemo
# Windows(classpath 分隔符为分号)
javac -cp modules\core\target\jts-core-1.21.0-SNAPSHOT.jar -d target\demo \
modules\core\src\main\java\org\locationtech\jts\operation\polygonize\HoleCounterDemo.java
输出:
holes = 1
21.8.5 配套的单测
新类必须带测试。用 JUnit 3 风格写到与主类同包,并继承 GeometryTestCase:
package org.locationtech.jts.operation.polygonize;
import org.locationtech.jts.geom.Geometry;
import test.jts.GeometryTestCase;
public class HoleCounterDemoTest extends GeometryTestCase {
public HoleCounterDemoTest(String name) {
super(name);
}
public void testSinglePolygonNoHole() {
Geometry g = read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
assertEquals(0, HoleCounterDemo.countHoles(g));
}
public void testPolygonWithOneHole() {
Geometry g = read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))");
assertEquals(1, HoleCounterDemo.countHoles(g));
}
public void testMultiPolygon() {
Geometry g = read("MULTIPOLYGON ("
+ "((0 0, 10 0, 10 10, 0 10, 0 0)),"
+ "((20 0, 30 0, 30 10, 20 10, 20 0), (22 2, 24 2, 24 4, 22 4, 22 2)))");
assertEquals(1, HoleCounterDemo.countHoles(g));
}
public void testEmpty() {
assertEquals(0, HoleCounterDemo.countHoles(read("GEOMETRYCOLLECTION EMPTY")));
assertEquals(0, HoleCounterDemo.countHoles(null));
}
public void testDoesNotModifyInput() {
Geometry g = read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
String before = g.toString();
HoleCounterDemo.countHoles(g);
assertEquals(before, g.toString());
}
}
五个用例分别覆盖:无洞、有洞、多面、空与 null、输入不可变。这是维护者 review PR 时最常问的五件事。
21.9 贡献流程:从 ECA 到 PR
21.9.1 动手之前:先确认贡献可接受
CONTRIBUTING.md 开篇就写:JTS 是成熟且被广泛使用的库,贡献会被仔细审查,以确保不损害代码质量。建议先发邮件到 jts-dev 邮件列表说明你要做什么,确认这个改动是可接受的贡献,再动手写代码。这一条能省掉你几天的工作——有些“改进”维护者已经明确不做(比如引入新的第三方依赖、改变默认精度策略、给 Geometry 加大量便利方法)。
21.9.2 四道硬门槛
| 门槛 | 具体要求 | 不满足的后果 |
|---|---|---|
| ECA | 用 GitHub 提交邮箱签署 Eclipse Contributor Agreement | CI 会检查提交者签名状态,未签直接拦 |
| 许可证头 | 每个源文件带标准 header(doc/sample_java_header.txt) |
Checkstyle 构建失败 |
| Javadoc | 类与公开方法都要有 | Review 要求补 |
| 测试 | 必须带单测;bugfix 的单测要在没修复时失败 | Review 直接打回 |
ECA 这一条最容易被国内开发者忽略。它不是 CLA(版权转让),只是声明“我有权提交这段代码”,签一次终身有效,但它绑定提交邮箱——如果你 git 配置的是公司邮箱而 ECA 用私人邮箱签的,CI 会判定未签。
21.9.3 提交规范:sign-off 与 label
# 关键:-s 会在 commit message 末尾加 Signed-off-by 行
git commit -s -m "Add HoleCounter utility to polygonize package"
# 查看是否加上了
git log -1 --format=full
# 期望输出片段:
# Add HoleCounter utility to polygonize package
#
# Signed-off-by: Your Name <you@example.com>
PR 上还要打标签,官方要求至少两类:
- 类型标签:
enhancement、bug、api - 模块标签:
jts-core、jts-io、jts-app、jts-doc、jts-build
打标签不是形式主义:JTS 用 milestone 管理发布,没有标签的 PR 不会被排进发布计划。
21.9.4 PR 检查清单
下面这份清单按维护者 review 的顺序排列,提交前逐条过:
┌───────────────────────────────────────────────────────────────┐
│ PR 自检清单 │
├───────────────────────────────────────────────────────────────┤
│ □ 1. 分支基于最新 master,无无关改动(不要顺手格式化别人的文件) │
│ □ 2. 每个新文件都有 license header │
│ □ 3. 公开 API 有 Javadoc,含 @param/@return/@throws │
│ □ 4. 新增行为有单测;bugfix 的单测在修复前会失败 │
│ □ 5. 几何语义类改动补了 XML 测试用例 │
│ □ 6. mvn clean install 通过(不跳过测试) │
│ □ 7. mvn verify 通过(PMD/Checkstyle 都过) │
│ □ 8. 更新 doc/JTS_Version_History.md 的对应版本条目 │
│ □ 9. commit 带 -s 签名 │
│ □ 10. PR 描述写清:动机、方案、影响面、如何验证 │
└───────────────────────────────────────────────────────────────┘
第 8 条经常漏。版本历史文件是发布说明的来源,维护者发布时会直接抄它;如果你的改动没写进去,用户就不知道有这个功能。
21.9.5 CI 会跑什么
JTS 的 GitHub Actions 会在 PR 上做两类检查:一类是 Eclipse 提交者状态检查(ECA),一类是构建正确性(mvn install)。如果 PR 显示红色,先点进日志看是第一类还是第二类失败:
- ECA 失败 → 去 Eclipse 账户确认签名邮箱与 commit 邮箱一致。
- 构建失败 → 本地
mvn clean install复现,注意 CI 用的 JDK 版本可能与你本地不同。 - PMD/Checkstyle 失败 → 本地跑
mvn verify就能复现。
21.10 发布流程(维护者视角)
普通贡献者不需要做发布,但理解发布流程有助于判断“我的改动会进哪个版本”。RELEASING.md 描述的流程大致分五段:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. 准备 │──>│ 2. 更新版本 │──>│ 3. 构建签名 │
│ milestone │ │ JTSVersion │ │ mvn -Drelease│
│ 版本历史 │ │ versions:set │ │ gpg 签名 │
│ Eclipse 评审 │ │ build-tools │ │ │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
┌──────────────┐ ┌─────▼────────┐
│ 5. 发布后 │<──│ 4. 部署 │
│ 升 SNAPSHOT │ │ mvn deploy │
│ 补空版本条目 │ │ GitHub Release│
└──────────────┘ └──────────────┘
几个关键细节:
- 版本号写两处:
org.locationtech.jts.JTSVersion里的MAJOR/MINOR/PATCH常量和RELEASE_INFO字符串,以及所有 POM。POM 用mvn versions:set -DnewVersion=1.19.0批量改,但build-tools/pom.xml要手工改(因为它通常比主版本超前一个小版本)。 RELEASE_INFO是开关:发布版设为空字符串"",快照版设为"SNAPSHOT"。JTSVersion.toString()会拼成1.20.0或1.21.0 SNAPSHOT。- 发布构建要关掉
allprofile:mvn clean install -Drelease,因为release属性存在时all不激活,只构建 core 与 io。 - Eclipse 评审约需两周,每月 1 日和 15 日排期,所以从“PR 合入”到“版本可下载”通常跨月。
- 发布后立刻升 SNAPSHOT 并补版本历史空条目,否则下一个人提交 PR 时不知道该往哪写。
21.11 构建问题排查手册
21.11.1 症状 → 原因 → 处置
| 症状 | 最可能原因 | 处置 |
|---|---|---|
Cannot find symbol: class WKTReader |
只构建了单个模块,core 未安装 | mvn install -pl modules/core 或加 -am |
checkstyle:check 报 Header 违规 |
新文件缺 header 或 header 格式差异 | 从 doc/sample_java_header.txt 复制 |
pmd:check 报 PrimitiveWrapperInstantiation |
写了 new Integer(x) |
改 Integer.valueOf(x) 或自动装箱 |
Could not resolve jdom2:2.0.6.1 |
私服缺依赖或离线 | 检查镜像、加 -o 前先确保本地有 |
Could not resolve jts-io-sde |
用了 -Parcsde 但没有 SDE 驱动 |
去掉 profile,或自行安装专有 jar |
javadoc:aggregate 失败 |
Javadoc 里有无效 @link |
修链接;1.21 刚有一批“修正失效 Javadoc 链接”的提交 |
OutOfMemoryError 在 assembly 阶段 |
默认堆太小 | export MAVEN_OPTS=-Xmx2g |
Fatal error compiling: invalid target release: 17 |
用了 JDK 8 编译但 POM 要求 17 | 本项目目标 1.8,遇到说明改错了 POM |
| 测试全绿但 XML 用例没过 | CoreGeometryXMLTest 只跑两个目录 |
手动加目录或用 TestRunner |
mvn site 卡住 |
site 插件在拉网络资源 | 用 checkstyle:check / pmd:check 替代 |
21.11.2 三个“看起来是 bug 其实不是”的案例
案例一:union 结果和 TestBuilder 不一致。 见 21.4.2:Geometry.union 默认走旧引擎,TestBuilder 的 OverlayNG.* 函数直接走新引擎。加 -Djts.overlay=ng 对齐。
案例二:isValid() 为 true 但 intersection 抛异常。 有效性(valid)与可叠加性(overlayable)不是一回事。自相交外的很多情况(比如两个多边形共享一段边界但坐标有 1e-15 差异)能通过 isValid(),却会让叠加引擎在节点化时崩掉。用 OverlayNGRobust 或先 PrecisionModel 规整坐标。
案例三:换了 JDK 后测试数量变少。 surefire 的包含模式依赖类名后缀,如果你在测试类里用了 @Ignore 或把类名改成 XxxTests,会被静默跳过。用 mvn test -pl modules/core 看 Tests run: 的行数,与上一次构建对比。
21.11.3 只跑一个测试类的正确姿势
# 单个类
mvn test -pl modules/core -Dtest=GeometryFixerTest
# 单个方法(surefire 2.19+ 语法)
mvn test -pl modules/core -Dtest=GeometryFixerTest#testPolygon
# 一个包下的所有测试
mvn test -pl modules/core -Dtest='org.locationtech.jts.geom.util.*Test'
# 失败即停,方便定位第一个错误
mvn test -pl modules/core -Dtest=GeometryFixerTest -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false
关于 -pl 与依赖:jts-core 没有内部模块依赖(只依赖 JUnit),所以 mvn test -pl modules/core 单独跑没问题。但 modules/io/common、modules/lab、modules/app 都依赖 jts-core,单独跑它们时必须保证本地仓库里有最新的 jts-core,否则测试编译用的是旧 jar:
# 先装上游,再跑下游模块的测试
mvn install -pl modules/core -DskipTests
mvn test -pl modules/lab
# 或者用 -am 让 Maven 连带构建依赖模块(注意 -am 会把下游依赖也重新构建一遍)
mvn test -pl modules/lab -am
21.12 如何快速定位源码中的实现
21.12.1 从 API 到实现的四步法
假设业务里调用了 geom.buffer(10),想知道它到底做了什么:
- 找入口:
Geometry.buffer(double)→ 一行BufferOp.bufferOp(this, distance)。 - 找主类:
BufferOp里看bufferOp的重载链,最终都汇聚到一个内部方法。 - 找算法核心:
BufferOp会构造OffsetCurveBuilder与BufferBuilder,真正的曲线生成在OffsetCurveBuilder。 - 找几何装配:
BufferBuilder用PolygonBuilder把线段装配成面,最后GeometryFactory出结果。
这条链在源码里都能用 IDE 的 Ctrl+Click 一路跳,但有两个断点:一是接口多态(OffsetCurveBuilder 在不同 join style 下走不同分支),二是 GeometryOverlay 这种包私有策略类(IDE 里跳不过去,要手动搜)。遇到跳不动的地方,用 grep -rn "类名" modules/core/src/main/java。
21.12.2 常用 grep 配方
# 1) 找某个系统属性的读取点
grep -rn "System.getProperty(\"jts\." modules/core/src/main/java
# 2) 找某个类的所有静态工厂方法
grep -n "public static" modules/core/src/main/java/org/locationtech/jts/operation/overlayng/OverlayNG.java
# 3) 找谁抛出了 TopologyException
grep -rn "throw new TopologyException" modules/core/src/main/java | head -20
# 4) 找测试里怎么用某个类(最快的用法示例来源)
grep -rln "CoverageValidator" modules/core/src/test/java
第 4 条特别有用:测试代码是最好的 API 用法文档。CoverageValidatorTest、GeometryFixerTest、OverlayNGRobustTest 这些类的可读性远高于 Javadoc,因为它们是“输入 → 期望输出”的完整对照。
21.12.3 阅读顺序建议
第一次通读 core,不要从 Geometry.java 开始(它太大且大量委托),建议按这个顺序:
Coordinate / CoordinateSequence → 数据怎么存
↓
GeometryFactory / Geometry 子类 → 对象怎么建
↓
Envelope / 谓词(Intersects 等) → 关系怎么判
↓
RelateOp / RelateNG → DE-9IM 怎么算
↓
OverlayOp / OverlayNG → 叠加怎么算
↓
BufferOp / OffsetCurveBuilder → 曲线怎么生成
↓
STRtree / Quadtree → 索引怎么加速
每层读的时候配合对应单测,一天能过一层,一周能把主干读完。
21.13 常见问题与避坑
21.13.1 构建相关
Q:mvn clean install 报 build-tools 找不到。
A:先单独装 build-tools:mvn install -pl build-tools。它不在 modules/ 下,路径是仓库根目录的 build-tools。
Q:只想跑测试不想打包,怎么最快?
A:mvn test -pl modules/core。但注意第一次仍然需要 install 让依赖模块进本地仓库。
Q:-DskipTests 和 -Dmaven.test.skip=true 有什么区别?
A:前者编译测试代码但不执行;后者连测试代码都不编译。JTS 文档里用的是 -DskipTests。后者更快,但会跳过测试代码的编译检查。
Q:Windows 上构建报路径过长。
A:JTS 的包路径本身就深(org/locationtech/jts/operation/overlayng/...),加上 target/site 很容易超过 260 字符。开启 Windows 长路径支持,或把仓库放到盘符根目录(如 D:\\jts)。
21.13.2 测试相关
Q:写了测试但 surefire 不跑。
A:检查三点——类名是否符合 *Test/Test*/*TestCase、测试类是否是 public、是否在 src/test/java 下。JUnit 4 注解测试类可以不继承 TestCase,但类名仍要匹配。
Q:GeometryTestCase 的 read() 报 ParseException 但 WKT 看起来没问题。
A:常见原因是 WKT 里有全角字符、中文逗号,或 LINEARRING 这种只在 TestBuilder 里支持的类型写法。标准 WKT 用 POLYGON 表达环。
Q:XML 用例里写 arg1="B" 但报参数越界。
A:一元函数没有第二个几何参数。arg1 是函数签名的第一个显式参数,不是“几何 B”。看 getParameterTypes() 的长度再决定写几个 arg。
Q:覆盖率怎么统计? A:JTS 仓库没有内置 jacoco 配置。想统计就在自己的分支加 jacoco 插件,或用 IDE 的覆盖率运行。
21.13.3 贡献相关
Q:我的 PR 会不会很久没人看? A:JTS 维护者都很忙,PR 平均响应时间是周级别。提高合入概率的办法:改动小、有测试、有版本历史条目、PR 描述里写清动机与影响面。
Q:能不能加第三方依赖?
A:基本不行。core 的依赖面被刻意控制(运行时依赖为零),io/common 才引入 json-simple。想加依赖先发邮件问。
Q:能不能改 Geometry 的公开方法签名?
A:几乎不可能被接受。JTS 有 ABI 兼容性跟踪(ABI Laboratory),改签名等于破坏所有下游。要走“新增方法 + @Deprecated 旧方法”的路线。
Q:改了默认行为怎么办?
A:参考 GeometryOverlay / GeometryRelate 的做法:加系统属性开关,默认保持旧行为,让用户主动切换。这是 JTS 处理“行为变更”的标准模式。
21.13.4 版本与发布相关
Q:SNAPSHOT 版本能用于生产吗?
A:不能。SNAPSHOT 会被反复覆盖,构建不可重现。生产必须锁正式版本(如 1.20.0)。
Q:1.21 什么时候发布?
A:doc/JTS_Version_History.md 里 1.21 的 Release Date 写的是 TBD。以 GitHub releases 页面为准。
Q:从哪里看某版本新增了什么?
A:doc/JTS_Version_History.md 是权威来源,按版本倒序列出 New Features / Functionality Improvements / Bug Fixes / Performance Improvements。写代码前查一下目标 API 是哪个版本引入的,能避免“本地能跑、客户环境 NoSuchMethodError”。
21.14 本章小结
- JTS 是 Maven 多模块工程,
jts-core用bundle打包(OSGi)并额外产出 test-jar,build-tools是所有质量规则的载体且必须最先构建。 allprofile 在“没有release属性”时自动激活,所以默认mvn clean install会构建 app/tests/lab/example;发布时用-Drelease关掉它们。- 编译目标是 Java 8,测试框架是 JUnit 4.13.1,
GeometryTestCase继承 JUnit 3 的TestCase,两种风格在仓库里混用。 - 测试是双轨制:JUnit 负责白盒与 API 边界,
modules/tests的 XML 用例负责几何语义的跨语言表达;CoreGeometryXMLTest默认只跑general与validate两个目录。 Geometry.union/intersection等方法通过包私有的GeometryOverlay转发,默认走旧引擎,-Djts.overlay=ng切到OverlayNGRobust;关系谓词同理受jts.relate控制。- 质量门禁由 PMD(
failurePriority=2)与 Checkstyle(Header、AvoidStarImport)组成,规则文件通过build-tools依赖注入,不是本地路径。 - 新类必须带 license header、Javadoc、私有构造器(工具类)、空输入守卫,并且不改动输入几何。
- 贡献流程的四道门槛是 ECA 签名、license header、Javadoc、测试;PR 要打类型标签与模块标签,commit 要
-s签名。 - 发布流程分准备、更新版本、签名构建、部署、发布后升 SNAPSHOT 五段,Eclipse 评审约两周,每月 1 日与 15 日排期。
- 定位源码的四步法是“找入口 → 找主类 → 找算法核心 → 找几何装配”,最快的用法参考永远是
src/test/java下的测试类。
相关资源
- JTS 官方仓库:https://github.com/locationtech/jts
- 开发指南(构建、QA、IDE 配置):https://github.com/locationtech/jts/blob/master/DEVELOPING.md
- 贡献指南(ECA、PR、label):https://github.com/locationtech/jts/blob/master/CONTRIBUTING.md
- 发布手册:https://github.com/locationtech/jts/blob/master/RELEASING.md
- 版本历史:https://github.com/locationtech/jts/blob/master/doc/JTS_Version_History.md
- Javadoc(1.20.0):https://locationtech.github.io/jts/javadoc/
- 本系列第 3 章(核心架构与源码结构):https://znlgis.github.io/gis/tutorial/jts/第03章-核心架构与源码结构/
- 本系列第 17 章(有效性与 Coverage 质检):https://znlgis.github.io/gis/tutorial/jts/第17章-有效性与Coverage质检/
- 本系列第 20 章(性能优化与最佳实践):https://znlgis.github.io/gis/tutorial/jts/第20章-性能优化与最佳实践/
- 本系列第 22 章(扩展开发与自定义函数):https://znlgis.github.io/gis/tutorial/jts/第22章-扩展开发与自定义函数/