第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 目标,会遇到两类问题:

  1. -source 8 -target 8 的废弃警告,编译不失败但输出大量 warning。
  2. 语言级别不一致导致“本地能编译、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 里,关键点有三个:

  1. 用 File → Import → Maven → Existing Maven Project,根目录选 jts 顶层目录。
  2. 初次构建会提示 checkstyle:check 的 lifecycle mapping 警告,用 quickfix 选 ignore;不要安装 maven-checkstyle-plugin 连接器,官方明确说它会装失败。
  3. 想看到实时 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,独立可跑,用于教学与自检

这张图有三个实用推论:

  1. 改 jts-core 必须重装后才能验证 app、tests、lab。用 mvn install -pl modules/app -am 让 Maven 连带构建上游模块。
  2. jts-lab 依赖 core 的 test-jar(<type>test-jar</type>),所以它能在测试里复用 GeometryTestCase;这也是为什么发布时 test-jar 需要一并安装。
  3. 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 用例:

  1. 决定归属目录:常规几何语义放 general/,校验与修复放 validate/,鲁棒性(可能触发异常)放 robust/。
  2. 新建 TestSnapToGrid.xml,写一个最简单的正向用例,确认能被解析。
  3. 本地跑 testrunner.sh -files <新文件>,看报告里的期望/实际差异。
  4. 把文件加进 CoreGeometryXMLTest.testFiles(...) 的目录参数(如果新建了目录)。
  5. 跑 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
  }
}

这段代码里有几处“看起来无所谓、实际会被要求改”的地方:

  1. 私有构造器:纯静态工具类不能有公开构造器。JTS 里不少老类没有照做(历史包袱),但新类会被要求。
  2. Javadoc 覆盖率:类必须有 Javadoc,公开方法必须有 @param、@return,可抛异常要有 @throws。
  3. 不改输入:方法注释里明确写 Input is never modified,并在实现里只读。
  4. 空几何早退: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│
                    └──────────────┘   └──────────────┘

几个关键细节:

  1. 版本号写两处:org.locationtech.jts.JTSVersion 里的 MAJOR/MINOR/PATCH 常量和 RELEASE_INFO 字符串,以及所有 POM。POM 用 mvn versions:set -DnewVersion=1.19.0 批量改,但 build-tools/pom.xml 要手工改(因为它通常比主版本超前一个小版本)。
  2. RELEASE_INFO 是开关:发布版设为空字符串 "",快照版设为 "SNAPSHOT"。JTSVersion.toString() 会拼成 1.20.0 或 1.21.0 SNAPSHOT。
  3. 发布构建要关掉 all profile:mvn clean install -Drelease,因为 release 属性存在时 all 不激活,只构建 core 与 io。
  4. Eclipse 评审约需两周,每月 1 日和 15 日排期,所以从“PR 合入”到“版本可下载”通常跨月。
  5. 发布后立刻升 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),想知道它到底做了什么:

  1. 找入口:Geometry.buffer(double) → 一行 BufferOp.bufferOp(this, distance)。
  2. 找主类:BufferOp 里看 bufferOp 的重载链,最终都汇聚到一个内部方法。
  3. 找算法核心:BufferOp 会构造 OffsetCurveBuilder 与 BufferBuilder,真正的曲线生成在 OffsetCurveBuilder。
  4. 找几何装配: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 是所有质量规则的载体且必须最先构建。
  • all profile 在“没有 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 下的测试类。

相关资源