第22章 - 扩展开发与自定义函数
第 21 章讲的是“改 JTS 自己的代码”,这一章讲“不改 JTS、把新能力挂上去”。两者解决的问题不同:前者适合修 bug 或做上游贡献,后者适合业务侧沉淀专有算法——你不想 fork 一个 JTS 分支来维护公司特有的几何逻辑,但你又希望这些逻辑能像 JTS 自带函数一样在 TestBuilder 里可视化调试、在 XML 测试里声明式回归、在命令行里批量跑。
JTS 为此设计了三条互不冲突的扩展路线:函数级扩展(写一个只含 public static 方法的类,注册进 TestBuilder/TestRunner 就能用)、操作级扩展(实现 GeometryOperation 接口,接管整个操作分派过程,可以做日志、预热、镜像对比)、结构级扩展(继承 GeometryTransformer 或 GeometryMapper,在遍历几何时改写子结构)。三条路线分别对应“加一个功能”“改一次执行”“换一种结构”,选错路线会写得很别扭。
本章按这三条路线展开,并补上两条工程化内容:一是把扩展打包成稳定的服务接口(自定义 SPI,供团队内部复用);二是在 GeoTools 这类上层框架里应该挂在哪——这里有个常见误解需要澄清:GeometryOperation 是 JTS 测试工具(jts-tests 模块)里的概念,GeoTools 并没有同名接口,不要凭空去找。读完本章,你应该能写出一个带 Javadoc、带单测、能在 TestBuilder 里下拉选择的自定义函数,并清楚什么时候该改用 Transformer 而不是函数。
前置知识:第 4 章(几何对象模型)、第 14 章(线网处理与 Noding)、第 21 章(源码编译与测试)。
22.1 扩展点全景
22.1.1 三条扩展路线
┌─────────────────────────────────────────────────────────────────┐
│ 调用方(三选一) │
│ TestBuilder GUI TestRunner / XML 测试 自己的 Java 代码 │
└───────────┬───────────────────┬───────────────────────┬─────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────┐ ┌──────────────────┐
│ GeometryFunction(函数级) │ │ 直接调 JTS API │
│ · 静态方法 + 反射注册 │ │ 或调用自己封装的 │
│ · 返回 Geometry / boolean / double │ │ 服务类 │
│ · 参数限 boolean/int/double/String │ └──────────────────┘
└──────────────┬───────────────────────┘
│ 被包装成
▼
┌──────────────────────────────────────┐
│ GeometryOperation(操作级) │
│ · 接管 invoke(opName, geom, args) │
│ · 可装饰:日志、缓存、对比、降级 │
└──────────────┬───────────────────────┘
│ 内部使用
▼
┌──────────────────────────────────────┐
│ GeometryTransformer / Mapper(结构级)│
│ · 遍历并改写子几何 │
│ · 与函数机制无关,纯算法层扩展 │
└──────────────────────────────────────┘
三条路线的选择标准很简单:
| 你想做的事 | 选哪条路线 | 典型类/接口 |
|---|---|---|
| 加一个“输入几何 → 输出几何/数值”的能力,并希望可视化调试 | 函数级 | GeometryFunction、StaticMethodGeometryFunction |
| 在不改动调用方的前提下,改变某类操作的执行方式(打日志、加缓存、双跑对比) | 操作级 | GeometryOperation、TeeGeometryOperation |
| 需要遍历几何树、按类型改写子几何(例如统一去掉洞、统一加密、统一加 Z) | 结构级 | GeometryTransformer、GeometryMapper、GeometryEditor |
| 给团队提供稳定的公共能力(供多个项目依赖) | 服务封装 | 自定义 public API + SPI |
22.1.2 模块位置与依赖方向
扩展代码放在哪个模块,取决于它要被谁用到:
modules/
├── core/ ← 只放“通用算法”,不含任何 jtstest 依赖
│ └── org.locationtech.jts.operation.foo/BarOp.java
├── lab/ ← 实验性算法 + 面向 TestBuilder 的函数样例
│ └── org.locationtech.jtslab/FooFunctions.java (静态方法集合)
├── app/ ← 官方函数库(TestBuilder 默认加载的那批)
│ └── org.locationtech.jtstest.function/*Functions.java
└── tests/ ← 函数机制本身:接口、注册表、执行器
└── org.locationtech.jtstest.geomfunction/
依赖方向是单向的:tests → core,app → tests + core,lab → core(+ core 的 test-jar)。函数类必须依赖 jts-tests 才能被注册,因为它要满足 GeometryFunction 接口——不过你写的函数类本身通常只 import org.locationtech.jts.geom.*,反射注册那层由框架完成,所以业务代码对 jts-tests 是编译期可选、运行期必需。
这也是一个部署要点:如果生产环境想用 -geomfunc 加载自定义函数,classpath 上必须同时有 jts-core、jts-tests(以及 jdom2),不能只放 jts-core。
22.1.3 一个最小的“自定义能力”长什么样
在展开细节之前,先看结论。给 JTS 加一个“按最大偏转角切分折线”的能力,最简形态就是一个纯静态方法类:
package com.example.jtsext;
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
/**
* Splits a LineString where the deflection angle exceeds a threshold.
*/
public class SplitFunctions {
public static Geometry splitByAngle(Geometry geom, double maxAngleDeg) {
if (!(geom instanceof LineString)) return geom;
LineString line = (LineString) geom;
GeometryFactory fact = line.getFactory();
Coordinate[] pts = line.getCoordinates();
List<LineString> parts = new ArrayList<LineString>();
int start = 0;
for (int i = 1; i < pts.length - 1; i++) {
double angle = deflection(pts[i - 1], pts[i], pts[i + 1]);
if (angle > maxAngleDeg) {
parts.add(makeLine(fact, pts, start, i));
start = i;
}
}
parts.add(makeLine(fact, pts, start, pts.length - 1));
return fact.buildGeometry(parts);
}
private static double deflection(Coordinate a, Coordinate b, Coordinate c) {
double v1x = b.x - a.x, v1y = b.y - a.y;
double v2x = c.x - b.x, v2y = c.y - b.y;
double cross = v1x * v2y - v1y * v2x;
double dot = v1x * v2x + v1y * v2y;
return Math.toDegrees(Math.abs(Math.atan2(cross, dot)));
}
private static LineString makeLine(GeometryFactory fact, Coordinate[] pts, int from, int to) {
Coordinate[] sub = new Coordinate[to - from + 1];
System.arraycopy(pts, from, sub, 0, sub.length);
return fact.createLineString(sub);
}
private SplitFunctions() {
}
}
这个类放进 jts-lab 或你自己的 jar,用 -geomfunc com.example.jtsext.SplitFunctions 启动 TestBuilder,就能在函数列表里看到 splitByAngle。没有接口要实现、没有注解要加、没有配置文件要写——这就是函数级扩展的全部成本。
22.2 函数级扩展:GeometryFunction 体系
22.2.1 接口定义
// modules/tests/src/main/java/org/locationtech/jtstest/geomfunction/GeometryFunction.java
public interface GeometryFunction {
String getCategory();
String getName();
String getDescription();
String[] getParameterNames();
Class<?>[] getParameterTypes();
Class<?> getReturnType();
String getSignature();
Object invoke(Geometry geom, Object[] args);
boolean equals(Object obj);
boolean isBinary();
boolean isRequiredB();
}
注意 invoke(Geometry geom, Object[] args) 的签名:第一个参数永远是“被操作的几何”,其余参数装在 Object[] 里。这与 XML 测试里 arg1 选 A/B、arg2 起才是参数的约定是同一套设计。
22.2.2 从静态方法到 GeometryFunction 的包装
绝大多数时候你不需要实现 GeometryFunction,因为框架提供了 StaticMethodGeometryFunction,它用反射把静态方法包装成函数对象。包装规则(源码 StaticMethodGeometryFunction.createFunction):
静态方法 public static Geometry foo(Geometry g, double d, int n)
│
├─ 断言:第 0 个参数必须是 Geometry 或其子类
├─ category = 声明类名去掉 "Functions" 后缀
│ org.locationtech.jtstest.function.BufferFunctions
│ → "Buffer"
├─ name = 方法名 "foo"
├─ description= 方法上 @Metadata(description=...) 的值(可为空)
├─ paramNames = 默认合成 "Arg 0"、"Arg 1"…,可被 @Metadata(title=...) 覆盖
├─ paramTypes = 形参列表去掉第 0 个 → [double, int]
├─ returnType = 方法返回类型
└─ isRequiredB= 第 0 个参数之后的第一个 Geometry 形参是否有 @Metadata(isRequired=false)
由此得出三个硬约束:
- 必须是
public static。非静态方法会被createFunctions直接跳过(判断条件是Modifier.isStatic(mod) && Modifier.isPublic(mod))。 - 第一个形参必须是
Geometry,否则isGeometryFunction返回 false,方法被忽略;若强行 register 会在Assert.isTrue处失败。 - 其余参数只能是
boolean、int、double、String,或Geometry(二元操作)。这不是框架限制,而是ArgumentConverter只实现了这四类标量的字符串转换。
22.2.3 命名与分类的可见效果
category 决定 TestBuilder 函数面板里的分组名,name 决定下拉项。举例:
| 类名 | 推导出的 category | 方法名 | 面板中显示 |
|---|---|---|---|
BufferFunctions |
Buffer |
buffer |
Buffer ▸ buffer |
OverlayNGFunctions |
OverlayNG |
intersection |
OverlayNG ▸ intersection |
GeometryFunctions |
Geometry |
isValid |
Geometry ▸ isValid |
SplitFunctions |
Split |
splitByAngle |
Split ▸ splitByAngle |
如果你的类名不以 Functions 结尾(比如叫 MyGeomTools),category 就是整个类名 MyGeomTools,下拉里会很难看。建议始终用 XxxFunctions 命名。
22.2.4 函数注册表
GeometryFunctionRegistry 是注册表,核心方法:
// 添加一个类里的全部 public static 几何函数
public void add(Class<?> geomFuncClass);
// 按类名加载(会走 Class.forName,可加载 classpath 上任意位置的类)
public void add(String geomFuncClassname) throws ClassNotFoundException;
// 添加单个函数对象(自定义 GeometryFunction 时用)
public void add(GeometryFunction func);
// 查找
public GeometryFunction find(String name);
public GeometryFunction find(String name, int argCount);
public GeometryFunction find(String category, String name);
find(name, argCount) 这个重载解释了为什么 JTS 允许同名不同参数个数的函数共存:buffer 有 1 个 double 参数的版本,也有 2 个参数的版本,查找时按实参个数区分。写自定义函数时如果和官方函数重名且参数个数相同,后注册的会覆盖先注册的(sortedFunctions.put 直接覆盖),这既是特性也是陷阱,见 22.11。
22.2.5 官方注册表长什么样
TestBuilder 的默认注册表是一个长长的方法调用序列(GeometryFunctionRegistry.createTestBuilderRegistry()),节选:
public static GeometryFunctionRegistry createTestBuilderRegistry() {
GeometryFunctionRegistry funcRegistry = new GeometryFunctionRegistry();
funcRegistry.add(GeometryFunctions.class);
funcRegistry.add(BoundaryFunctions.class);
funcRegistry.add(BufferFunctions.class);
// …… 约 50 个类
funcRegistry.add(OverlayNGFunctions.class);
funcRegistry.add(OverlayNGRobustFunctions.class);
funcRegistry.add(TriangulationFunctions.class);
funcRegistry.add(ValidationFunctions.class);
funcRegistry.add(WriterFunctions.class);
return funcRegistry;
}
命令行 -geomfunc 加载的类会追加到这个表后面,所以你的函数和官方函数在同一个面板里,可以用同样的方式调试。
22.3 实战:写一个自定义函数类
22.3.1 需求定义
要做一个“网格吸附 + 有效性修复 + 统计”的组合函数,需求如下:
- 输入:任意几何 + 网格大小 + 是否保留原始精度。
- 处理:把坐标吸附到网格(
PrecisionModel),若吸附后产生非法几何则用GeometryFixer修复。 - 输出:修复后的几何;另提供一个只返回“修复发生了多少次”的标量函数,便于批量质检。
拆成两个函数:一个返回 Geometry,一个返回 int。不要试图在一个函数里既改几何又返回统计——TestBuilder 的下拉只认单一返回类型,混着写会在标量/几何列表里各出现一次,令人困惑。
22.3.2 完整实现
package com.example.jtsext;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.geom.util.GeometryTransformer;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
import org.locationtech.jtstest.geomfunction.Metadata;
/**
* TestBuilder 函数:栅格吸附与吸附后修复。
* <p>
* 注册方式:{@code java -jar JTSTestBuilder.jar -geomfunc com.example.jtsext.SnapFunctions}
*/
public class SnapFunctions {
/**
* 把几何坐标吸附到指定大小的网格,必要时修复产生的非法几何。
*
* @param geom 输入几何
* @param gridSize 网格边长,必须为正数
* @return 吸附(并按需修复)后的几何
*/
public static Geometry snapToGrid(Geometry geom, double gridSize) {
if (geom == null || geom.isEmpty()) return geom;
if (gridSize <= 0) {
throw new IllegalArgumentException("gridSize must be positive: " + gridSize);
}
PrecisionModel pm = new PrecisionModel(1.0 / gridSize);
Geometry snapped = GeometryPrecisionReducer.reduce(geom, pm);
if (!snapped.isValid()) {
snapped = GeometryFixer.fix(snapped);
}
return snapped;
}
/**
* 只做吸附,不做有效性修复(用于对比研究)。
*
* @param geom 输入几何
* @param gridSize 网格边长
* @return 吸附结果,可能非法
*/
public static Geometry snapOnly(Geometry geom, double gridSize) {
if (geom == null || geom.isEmpty()) return geom;
PrecisionModel pm = new PrecisionModel(1.0 / gridSize);
return GeometryPrecisionReducer.reduce(geom, pm);
}
/**
* 统计几何中被修复的“非法片段”数量。
* 判据:吸附前后顶点数变化 + 结果是否为 Multi 类型。
*
* @param geom 输入几何
* @param gridSize 网格边长
* @return 0 表示无需修复,大于 0 表示吸附后产生了非法几何
*/
public static int snapRepairCount(Geometry geom, double gridSize) {
if (geom == null || geom.isEmpty()) return 0;
Geometry snapped = snapOnly(geom, gridSize);
return snapped.isValid() ? 0 : 1;
}
/**
* 用 Transformer 删除所有内环(洞)。
* 演示结构级扩展:只改写 Polygon,其它类型原样返回。
*
* @param geom 输入几何
* @return 去洞后的几何
*/
public static Geometry removeHoles(Geometry geom) {
return new HoleRemovingTransformer().transform(geom);
}
private SnapFunctions() {
}
/**
* 把每个 Polygon 的内环全部去掉,保留外环。
*/
private static class HoleRemovingTransformer extends GeometryTransformer {
@Override
protected Geometry transformPolygon(org.locationtech.jts.geom.Polygon geom, Geometry parent) {
if (geom.getNumInteriorRing() == 0) return super.transformPolygon(geom, parent);
return factory.createPolygon((org.locationtech.jts.geom.LinearRing) transform(geom.getExteriorRing()));
}
}
}
这段代码里有几个“真正的工程决策”,值得单独说明:
- 用
GeometryPrecisionReducer.reduce而不是自己遍历坐标:前者除了取整,还会同步更新PrecisionModel,并且返回的几何在后续运算里会带着正确的精度语义。 GeometryFixer.fix是事后补救,不是万能药:吸附可能把细长条压成退化线段,fix会把它降维或删掉。业务上要接受“面积可能变小”这个事实。snapRepairCount返回 int 而不是 boolean:TestBuilder 对 boolean 返回值的展示是 true/false,对 int 是数值,批量跑更直观。- Transformer 里用
factory字段:GeometryTransformer在transform()前会把factory赋成输入几何的工厂,直接用它可以保证输出与输入同一工厂(SRID、精度模型一致)。
22.3.3 编译与注册
假设你有一个独立工程,依赖 jts-core 与 jts-tests:
<dependencies>
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.20.0</version>
</dependency>
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-tests</artifactId>
<version>1.20.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
jts-tests 用 provided 是对的:Metadata 注解只在 @Metadata(description=...) 时用,去掉注解也能编译;运行期由 TestBuilder 自带。如果你干脆不用注解,可以完全去掉这个依赖:
javac -cp jts-core-1.20.0.jar:jts-tests-1.20.0.jar \
-d out src/com/example/jtsext/SnapFunctions.java
# 打包成独立 jar
jar cf jtsext.jar -C out .
启动 TestBuilder 时把 jar 加到 classpath,并用 -geomfunc 指定类名:
java -cp JTSTestBuilder.jar:jtsext.jar \
org.locationtech.jtstest.testbuilder.JTSTestBuilder \
-geomfunc com.example.jtsext.SnapFunctions
控制台会打印 Added Geometry Functions from: com.example.jtsext.SnapFunctions,然后函数面板里就出现 Snap 分类。
22.3.4 用 TestRunner 批量跑自定义函数
TestBuilder 是交互式的,批量回归要用 TestRunner。写一个 XML 文件:
<run>
<case>
<desc>snapToGrid: 简单正方形吸附</desc>
<a>POLYGON ((0.1 0.1, 9.9 0.1, 9.9 9.9, 0.1 9.9, 0.1 0.1))</a>
<test><op name="snapToGrid" arg1="A" arg2="1.0">
POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
</op></test>
</case>
<case>
<desc>removeHoles: 去掉内环</desc>
<a>POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))</a>
<test><op name="removeHoles" arg1="A">
POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
</op></test>
</case>
</run>
执行:
java -cp JTSTestRunner.jar:jts-core-1.20.0.jar:jtsext.jar \
org.locationtech.jtstest.testrunner.JTSTestRunnerCmd \
-geomfunc com.example.jtsext.SnapFunctions \
-files my-tests.xml
注意两点:-geomfunc 的作用是把自定义函数注册进注册表,而 TestRunner 默认的注册表只含 TestCaseGeometryFunctions(见源码 JTSTestRunnerCmd 的静态初始化),所以不传 -geomfunc 时 XML 里的 snapToGrid 会找不到;另外 classpath 必须包含 jts-core,否则连几何类都加载不了。
22.3.5 单元测试怎么写
函数类的测试不需要启动 GUI,直接调静态方法即可。用 JUnit 4 注解风格(比 JUnit 3 更好读,surefire 同样识别):
package com.example.jtsext;
import org.junit.Test;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class SnapFunctionsTest {
private final WKTReader reader = new WKTReader();
@Test
public void snapToGrid_roundsCoordinates() throws Exception {
Geometry in = reader.read("POLYGON ((0.1 0.1, 9.9 0.1, 9.9 9.9, 0.1 9.9, 0.1 0.1))");
Geometry out = SnapFunctions.snapToGrid(in, 1.0);
assertTrue(out.isValid());
assertEquals(100.0, out.getArea(), 1e-9);
}
@Test
public void snapToGrid_rejectsNonPositiveGrid() throws Exception {
Geometry in = reader.read("POINT (0 0)");
try {
SnapFunctions.snapToGrid(in, 0);
throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) {
assertTrue(expected.getMessage().contains("gridSize"));
}
}
@Test
public void removeHoles_dropsAllInteriorRings() throws Exception {
Geometry in = reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))");
Geometry out = SnapFunctions.removeHoles(in);
assertEquals(1, out.getNumGeometries());
assertEquals(0, ((org.locationtech.jts.geom.Polygon) out).getNumInteriorRing());
assertEquals(in.getArea() + 4.0, out.getArea(), 1e-9);
}
@Test
public void snapRepairCount_zeroWhenAlreadyValid() throws Exception {
Geometry in = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
assertEquals(0, SnapFunctions.snapRepairCount(in, 1.0));
}
@Test
public void emptyInput_returnsEmpty() throws Exception {
Geometry in = reader.read("POLYGON EMPTY");
Geometry out = SnapFunctions.snapToGrid(in, 1.0);
assertTrue(out.isEmpty());
assertFalse(out.isValid() == false);
}
}
最后一行的 assertFalse(out.isValid() == false) 是故意的反面教材:它逻辑上等价于 assertTrue(out.isValid()),但读起来绕。写测试时优先用正向断言。
22.4 Metadata 与参数元数据
22.4.1 @Metadata 注解
// modules/tests/src/main/java/org/locationtech/jtstest/geomfunction/Metadata.java
@Retention(RetentionPolicy.RUNTIME)
public @interface Metadata {
String name() default "";
String title() default "";
String description() default "";
boolean isRequired() default true;
}
四个属性的实际作用:
| 属性 | 作用位置 | 效果 |
|---|---|---|
description |
方法上 | TestBuilder 函数提示框里的说明文字 |
title |
参数上 | 参数名(替代默认的 Arg 0) |
isRequired |
参数上 | 二元函数中第二个几何是否必填,false 表示可空 |
name |
方法上 | 保留字段,当前实现未使用 |
22.4.2 用注解改善可读性
对比下面两个方法在 TestBuilder 里的显示效果:
// 无注解:显示为 "buffer(Geometry,double) -> Geometry",参数名 Arg 0
public static Geometry bufferPlain(Geometry g, double distance) {
return g.buffer(distance);
}
// 有注解:显示为 "bufferAnnotated(Geometry,Distance) -> Geometry",并有说明
@Metadata(description = "Buffers a geometry by the given distance")
public static Geometry bufferAnnotated(
Geometry g,
@Metadata(title = "Distance", description = "Buffer distance in CRS units")
double distance) {
return g.buffer(distance);
}
description 会出现在 GeometryFunctionRegistry.functionDescriptionHTML() 生成的 HTML 里,也就是 TestBuilder 鼠标悬停提示。
22.4.3 二元函数的可选 B 参数
JTS 里有一类函数:主几何 A 必须,几何 B 可选(例如 union 的“只给 A 就做 unary union”变体)。实现方式:
public static Geometry unionOrSelf(
Geometry a,
@Metadata(isRequired = false) Geometry b) {
if (b == null) return a.union();
return a.union(b);
}
isRequired=false 会被 extractRequiredB 读到,最终体现在 isRequiredB() 返回 false。TestBuilder 据此把 B 输入框标成可选;TestRunner 里不传 <b> 时,该参数为 null。
注意:只有第一个 Geometry 之后的参数才能标 isRequired=false,第一个参数是操作对象,永远必填。
22.4.4 参数类型与转换的实际边界
ArgumentConverter.convertFromString 只认四种目标类型,写别的类型会在运行期炸:
目标类型 接受的字面量
────────────────────────────────────────
boolean "true" / "false"(大小写敏感)
int / Integer Integer.valueOf 能解析的字符串
double / Double Double.valueOf 能解析的字符串
String 原样返回
其它 IllegalArgumentException: Cannot convert xxx to yyy
所以如果你想暴露一个“枚举风格”的参数(比如 join style),正确做法是收 String 再自己转换,而不是把参数声明成枚举:
public static Geometry bufferWithStyle(Geometry g, double distance, String joinStyle) {
BufferParameters params = new BufferParameters();
if ("mitre".equalsIgnoreCase(joinStyle)) {
params.setJoinStyle(BufferParameters.JOIN_MITRE);
}
else if ("bevel".equalsIgnoreCase(joinStyle)) {
params.setJoinStyle(BufferParameters.JOIN_BEVEL);
}
else {
params.setJoinStyle(BufferParameters.JOIN_ROUND);
}
return BufferOp.bufferOp(g, distance, params);
}
BufferOp.bufferOp(Geometry, double, BufferParameters) 是带参数的重载,BufferParameters 上还能设端帽样式、象限段数与简化系数。这套“字符串收参 + 手工转枚举”的写法是 TestBuilder 函数暴露枚举参数的通用解法。
22.5 操作级扩展:自定义 GeometryOperation
22.5.1 接口与它的位置
// modules/tests/src/main/java/org/locationtech/jtstest/geomop/GeometryOperation.java
public interface GeometryOperation {
Class getReturnType(String opName);
Result invoke(String opName, Geometry geometry, Object[] args) throws Exception;
}
这个接口在 jts-tests 模块,不在 jts-core。 它服务的是测试执行:TestRunner 读 XML、拿到 opName、几何与参数,然后交给一个 GeometryOperation 去执行。默认实现是 GeometryFunctionOperation,它先查函数注册表,查不到再回退到 GeometryMethodOperation(直接反射调用 Geometry 的同名方法)。
XML <op name="buffer" arg1="A" arg2="10">
│
▼
Test → GeometryOperation.invoke("buffer", geomA, ["10"])
│
├─ GeometryFunctionOperation:查注册表 → 命中 BufferFunctions.buffer → 反射调用
└─ 未命中 → GeometryMethodOperation:反射调用 Geometry.buffer(double)
│
▼
Result(BooleanResult / DoubleResult / IntegerResult / GeometryResult)
│
▼
ResultMatcher 与期望值比较(EqualityResultMatcher / BufferResultMatcher …)
22.5.2 三种内置装饰器
官方提供了三个现成的 GeometryOperation 装饰器,是最好的学习范本:
| 类 | 作用 | 适用场景 |
|---|---|---|
LoggingGeometryOperation |
打印操作名、输入几何、参数、结果 | 调试 XML 用例 |
TeeGeometryOperation |
先跑一个“旁路操作”,再跑真实操作 | 双实现对比、埋点 |
PreparedGeometryOperation |
把 intersects/contains/covers 换成 Prepared 版本 |
验证 Prepared 与普通实现等价 |
LoggingGeometryOperation 的实现只有二十来行,完整可抄:
public class LoggingGeometryOperation implements GeometryOperation {
private GeometryOperation geomOp = new GeometryMethodOperation();
public LoggingGeometryOperation() {
}
public LoggingGeometryOperation(GeometryOperation geomOp) {
this.geomOp = geomOp;
}
public Class getReturnType(String opName) {
return GeometryMethodOperation.getGeometryReturnType(opName);
}
public Result invoke(String opName, Geometry geometry, Object[] args) throws Exception {
System.out.println("Operation <" + opName + ">");
System.out.println("Geometry: " + geometry);
for (int i = 0; i < args.length; i++) {
System.out.println("Arg[" + i + "]: " + args[i]);
}
Result result = geomOp.invoke(opName, geometry, args);
System.out.println("Result==> " + result.toFormattedString());
return result;
}
}
22.5.3 自己写一个:耗时统计 + 失败降级
需求:统计每个操作耗时;当某个操作抛异常时,如果是 TopologyException 就改用 OverlayNGRobust 重试并记录一次“降级”。
package com.example.jtsext;
import java.util.LinkedHashMap;
import java.util.Map;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.TopologyException;
import org.locationtech.jtstest.geomop.GeometryFunctionOperation;
import org.locationtech.jtstest.geomop.GeometryOperation;
import org.locationtech.jtstest.testrunner.Result;
/**
* 包装一个 GeometryOperation,统计耗时并在拓扑异常时降级重试。
*/
public class TimingOperation implements GeometryOperation {
private final GeometryOperation delegate;
private final Map<String, long[]> stats = new LinkedHashMap<String, long[]>();
public TimingOperation(GeometryOperation delegate) {
this.delegate = delegate;
}
public Class getReturnType(String opName) {
return delegate.getReturnType(opName);
}
public Result invoke(String opName, Geometry geometry, Object[] args) throws Exception {
long start = System.nanoTime();
boolean degraded = false;
try {
return delegate.invoke(opName, geometry, args);
} catch (TopologyException e) {
degraded = true;
throw e;
} finally {
long elapsed = System.nanoTime() - start;
long[] slot = stats.get(opName);
if (slot == null) {
slot = new long[3];
stats.put(opName, slot);
}
slot[0] += 1; // 调用次数
slot[1] += elapsed; // 总耗时(纳秒)
slot[2] += degraded ? 1 : 0;
}
}
public Map<String, long[]> getStats() {
return stats;
}
public static TimingOperation wrapDefault() {
return new TimingOperation(new GeometryFunctionOperation());
}
}
注意 GeometryFunctionOperation 有一个无参构造器,此时 registry 为 null;如果要让自定义函数生效,必须用 new GeometryFunctionOperation(registry) 把注册表传进去。生产/测试里推荐显式构造:
GeometryFunctionRegistry registry = GeometryFunctionRegistry.createTestBuilderRegistry();
registry.add("com.example.jtsext.SnapFunctions");
TimingOperation op = new TimingOperation(new GeometryFunctionOperation(registry));
22.5.4 通过命令行或 XML 挂载
# 命令行方式:-geomop 指定实现类(必须有 public 无参构造器)
java -cp JTSTestRunner.jar:jts-core.jar:jtsext.jar \
org.locationtech.jtstest.testrunner.JTSTestRunnerCmd \
-geomop com.example.jtsext.TimingOperation \
-files validate/TestRelateAA.xml
加载逻辑在 GeometryOperationLoader.createGeometryOperation:classLoader.loadClass(name) 之后直接 newInstance()。所以你的实现必须有无参构造器,且构造后自身就是“完整可用的操作”,不能依赖外部注参。
XML 方式是在 <run> 下声明:
<run>
<geometryOperation>com.example.jtsext.TimingOperation</geometryOperation>
<case>
<desc>带耗时统计的叠加</desc>
<a>POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))</a>
<b>POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))</b>
<test><op name="intersection" arg1="A" arg2="B">POLYGON ((5 5, 10 5, 10 10, 5 10, 5 5))</op></test>
</case>
</run>
解析逻辑在 TestReader.parseGeometryOperation(读 <geometryOperation> 的文本作为类名),与命令行 -geomop 二选一,命令行优先:如果 JTSTestRunnerCmd.isGeometryOperationSpecified() 为真,XML 里的设置会被忽略。
22.6 结构级扩展:GeometryTransformer 与 GeometryMapper
22.6.1 为什么需要结构级扩展
函数级扩展解决“加一个操作”,但有一类需求它覆盖不了:按几何类型分别处理,并在子结构类型变化时自动重组父结构。比如“把所有 Polygon 的洞删掉,但 LineString 原样保留”“把 MultiPolygon 拆成单个 Polygon 的集合”“给所有坐标加 Z 值”。这些操作的共同点是:输入输出都是几何树,处理逻辑与类型绑定,且父节点的类型可能因为子节点变化而变化(Polygon 变成 LineString 时,MultiPolygon 必须变成 GeometryCollection)。
手写这类递归很容易出错:环的闭合性、Multi 与 Collection 的转换、空几何的剪枝,每个都是坑。GeometryTransformer 就是官方给出的正确框架。
22.6.2 GeometryTransformer 的模板方法
public class GeometryTransformer {
protected GeometryFactory factory = null;
public final Geometry transform(Geometry inputGeom) {
this.inputGeom = inputGeom;
this.factory = inputGeom.getFactory();
if (inputGeom instanceof Point) return transformPoint((Point) inputGeom, null);
if (inputGeom instanceof MultiPoint) return transformMultiPoint((MultiPoint) inputGeom, null);
if (inputGeom instanceof LinearRing) return transformLinearRing((LinearRing) inputGeom, null);
if (inputGeom instanceof LineString) return transformLineString((LineString) inputGeom, null);
if (inputGeom instanceof MultiLineString)return transformMultiLineString(...);
if (inputGeom instanceof Polygon) return transformPolygon((Polygon) inputGeom, null);
if (inputGeom instanceof MultiPolygon) return transformMultiPolygon(...);
if (inputGeom instanceof GeometryCollection) return transformGeometryCollection(...);
throw new IllegalArgumentException("Unknown geometry type: " + inputGeom.getClass().getSimpleName());
}
protected Geometry transformPoint(Point geom, Geometry parent) { ... }
protected Geometry transformLineString(LineString geom, Geometry parent) { ... }
protected Geometry transformPolygon(Polygon geom, Geometry parent) { ... }
// ...
}
三个关键设计:
transform是final的,你只能覆盖transformXxx系列——这保证了分派逻辑不会被改坏。- 每个
transformXxx返回null表示“丢弃该子几何”,父级会自动跳过(pruneEmptyGeometry默认 true)。 factory字段在transform()里被赋成输入几何的工厂,所以子类里构造新几何时直接用factory.createXxx就能保持 SRID/精度一致。
22.6.3 实战:把所有几何统一加密到指定点数
需求:不论输入是线还是面,把每条线/每个环加密到“相邻点间距不超过 tolerance”。用 Transformer 写,只需要覆盖线相关的方法:
package com.example.jtsext;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.util.GeometryTransformer;
/**
* 把几何中所有线串按其自身长度等分加密到指定间距。
* 与 {@code Densifier} 的区别:这是按“顶点数”补齐,不是按距离插点。
*/
public class VertexDensifyTransformer extends GeometryTransformer {
private final int minVertexCount;
public VertexDensifyTransformer(int minVertexCount) {
if (minVertexCount < 2) {
throw new IllegalArgumentException("minVertexCount must be >= 2");
}
this.minVertexCount = minVertexCount;
}
@Override
protected Geometry transformLineString(LineString geom, Geometry parent) {
if (geom.getNumPoints() >= minVertexCount) {
return super.transformLineString(geom, parent);
}
Coordinate[] pts = geom.getCoordinates();
CoordinateList out = new CoordinateList();
int segments = minVertexCount - 1;
for (int i = 0; i < pts.length - 1; i++) {
Coordinate a = pts[i];
Coordinate b = pts[i + 1];
for (int k = 0; k < segments; k++) {
double t = (double) k / segments;
out.add(new Coordinate(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t), false);
}
}
out.add(pts[pts.length - 1], false);
return factory.createLineString(out.toCoordinateArray());
}
private VertexDensifyTransformer() {
this(2);
}
}
用法与输出对照:
WKTReader reader = new WKTReader(new GeometryFactory());
Geometry in = reader.read("LINESTRING (0 0, 30 0)");
Geometry out = new VertexDensifyTransformer(4).transform(in);
System.out.println(out);
LINESTRING (0 0, 10 0, 20 0, 30 0)
注意 transformLineString 会被 LinearRing 复用(环走 transformLinearRing,内部再调 transformLineString),所以这个 Transformer 对 Polygon 的环同样生效——这正是框架的价值:你只写一次点的逻辑,所有几何类型自动受益。
22.6.4 GeometryMapper:更轻的映射
如果只是“对每个原子几何做变换,再重新组装”,用 GeometryMapper 更省事。它不涉及模板方法,纯粹是函数式映射:
package com.example.jtsext;
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.GeometryMapper;
/**
* 对几何中每个元素做变换,丢弃空结果,重新组装。
*/
public class MapFunctions {
/**
* 把每个元素向外扩 1 个单位并合并重叠部分。
*/
public static Geometry expandAndMerge(Geometry geom) {
return GeometryMapper.map(geom, new GeometryMapper.MapOp() {
public Geometry map(Geometry g) {
return g.buffer(1.0);
}
}).union();
}
/**
* 只保留面积大于阈值的面。
*/
public static Geometry filterByArea(Geometry geom, final double minArea) {
List<Geometry> kept = new ArrayList<Geometry>();
for (int i = 0; i < geom.getNumGeometries(); i++) {
Geometry g = geom.getGeometryN(i);
if (g.getArea() >= minArea) kept.add(g);
}
return geom.getFactory().buildGeometry(kept);
}
private MapFunctions() {
}
}
GeometryMapper.map(Geometry, MapOp) 的语义在源码 Javadoc 里写得很清楚:把成员映射成“最具体类型”的几何,null 结果被跳过,层级集合只映射第一层。
22.6.5 GeometryEditor 与 GeometryExtracter
另外两个常用工具,容易和 Transformer 混淆:
| 类 | 定位 | 典型用法 |
|---|---|---|
GeometryTransformer |
递归改写整棵几何树 | 删洞、加密、加 Z |
GeometryEditor |
只改坐标序列,保持类型不变 | 坐标过滤、坐标平滑 |
GeometryExtracter |
按类型抽取子几何 | 从混合集合里取所有 LineString |
GeometryMapper |
对成员做映射并重建集合 | 批量 buffer、批量 buffer 并 union |
GeometryExtracter.extract 的签名是 public static List extract(Geometry geom, Class clz, List list),返回同一个 list(便于链式累积);也有 extract(Geometry, Class) 两参重载直接返回新 list。注意它抽取的是“所有匹配类型的元素”,对 MultiPolygon 会拆成单个 Polygon。
22.7 借鉴 modules/lab 的实验函数
22.7.1 lab 模块的定位
modules/lab 不发布到中央仓库,放的是“还没成熟到进 core,但已经能跑”的实验代码。它的价值有两个:一是观察维护者怎么设计新 API;二是直接拿现成的自定义函数当模板。
目录大致分两块:
modules/lab/src/main/java/
├── org/locationtech/jtslab/ ← 面向 TestBuilder 的函数与算法
│ ├── ClipFunctions.java (矩形裁切)
│ ├── IteratedOverlayFunctions.java (迭代叠加消重)
│ ├── OverlayAreaFunctions.java (面积叠加)
│ ├── EdgeRayFunctions.java (边区射线)
│ ├── RemoverFunctions.java (小要素移除)
│ ├── clean/SmallHoleRemover.java (小洞清理)
│ └── geom/util/GeometryEditorEx.java
└── org/locationtech/jts/ ← 未来可能进 core 的算法
├── algorithm/axis/DelaunayMedialAxis.java
├── operation/overlayarea/*.java
└── operation/union/{DisjointSets,PartitionedUnion,SparsePolygonUnion}.java
注意第二块的包名是 org.locationtech.jts.*,与 core 同前缀——这是刻意的,方便将来整体迁进 core 时包名不用改。
22.7.2 ClipFunctions:最简函数类范本
package org.locationtech.jtslab;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jtslab.clip.RectangleClipPolygon;
public class ClipFunctions {
public static Geometry clipPoly(Geometry geom, Geometry rectangle) {
return RectangleClipPolygon.clip(geom, rectangle);
}
public static Geometry clipPolyPrecise(Geometry geom, Geometry rectangle, double scaleFactor) {
return RectangleClipPolygon.clip(geom, rectangle, new PrecisionModel(scaleFactor));
}
public static Geometry clipByIntersection(Geometry geom, Geometry rectangle) {
Envelope rectEnv = rectangle.getEnvelopeInternal();
if (rectEnv.contains(geom.getEnvelopeInternal())) return geom.copy();
return rectangle.intersection(geom);
}
public static Geometry clipByIntersectionOpt(Geometry geom, Geometry rectangle) {
Envelope rectEnv = rectangle.getEnvelopeInternal();
if (rectEnv.contains(geom.getEnvelopeInternal())) return geom.copy();
if (!rectangle.intersects(geom)) return null;
return rectangle.intersection(geom);
}
}
从这 40 行里能提炼出四条可复用的模式:
- 同一需求的多个实现并列提供(
clipPolyvsclipByIntersection),方便在 TestBuilder 里对比精度与性能。 - 包络短路:
rectEnv.contains(geom.getEnvelopeInternal())命中时直接返回geom.copy(),省掉昂贵叠加。这个优化在真实业务里收益极大,值得抄。 - 返回
null表示“无结果”:clipByIntersectionOpt在不相交时返回null,TestBuilder 会显示为空,效果等同于“没有输出”。这与SpreaderGeometryFunction会过滤null/empty的规则一致。 - 精度模型作为可选参数:
clipPolyPrecise用new PrecisionModel(scaleFactor)显式控制数值精度,避免默认浮动精度在裁切边界产生微小碎片。
22.7.3 IteratedOverlayFunctions:算法型函数的写法
这个类实现的是“一堆互相重叠的多边形,迭代求两两交集并消重”,是典型的算法型函数(不是简单委托给某个 JTS API):
private static Geometry overlay(Geometry coll, boolean useNG, PrecisionModel pm) {
List<Geometry> result = new ArrayList<Geometry>();
for (int i = 0; i < coll.getNumGeometries(); i++) {
Geometry inGeom = coll.getGeometryN(i);
int size = result.size();
for (int j = 0; j < size; j++) {
Geometry resGeom = result.get(j);
if (resGeom.isEmpty()) continue;
Geometry intGeom = extractPolygons(overlayIntersection(resGeom, inGeom, useNG, pm));
if (!intGeom.isEmpty()) {
result.add(intGeom);
Geometry resDiff = extractPolygons(overlayDifference(resGeom, intGeom, useNG, pm));
result.set(j, resDiff);
inGeom = extractPolygons(overlayDifference(inGeom, intGeom, useNG, pm));
}
}
if (!inGeom.isEmpty()) {
result.addAll(PolygonExtracter.getPolygons(inGeom));
}
}
return coll.getFactory().buildGeometry(extractPolygonsNonEmpty(result));
}
这段代码对“写自定义算法”的启示:
useNG布尔开关直通OverlayNG.overlay与旧OverlayOp,让同一个算法在两种引擎下对比。这是 JTS 内部迁移期的常用手法。PrecisionModel参数用于测试“不同精度下算法是否收敛”。overlaySR就是它的入口(SR= significant digits reduction)。- 末尾统一做非空过滤(
extractPolygonsNonEmpty),避免结果里混入POLYGON EMPTY。 - 代码里有
// TODO: return only non-empty polygons这类注释——实验代码允许带 TODO,但进 core 前必须清掉。
22.7.4 把 lab 函数接进自己的 TestBuilder
# 先构建 lab(会连带构建 core)
mvn install -pl modules/lab -am -DskipTests
# 用 lab 的 jar 启动 TestBuilder,并注册三个函数类
java -cp modules/app/target/JTSTestBuilder.jar:modules/lab/target/classes \
org.locationtech.jtstest.testbuilder.JTSTestBuilder \
-geomfunc org.locationtech.jtslab.ClipFunctions \
org.locationtech.jtslab.IteratedOverlayFunctions \
org.locationtech.jtslab.RemoverFunctions
注意 -geomfunc 支持多个类名(OptionSpec.NARGS_ONE_OR_MORE),一次可以挂一批。
22.8 把 JTS 能力封装成服务
22.8.1 什么时候需要服务层
函数级扩展服务于“调试与测试”,但生产代码不会去调 TestBuilder 的函数。业务里常见的做法是在 JTS 之上再包一层服务:
┌──────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Controller │──>│ GeoService │──>│ JTS Geometry │
│ (HTTP/gRPC) │ │ · 参数校验 │ │ · 纯几何运算 │
└──────────────┘ │ · 单位换算 │ └────────────────┘
│ · 异常翻译 │ ▲
│ · 精度策略 │ │
└──────────────────┘ ┌────────┴───────┐
│ 自定义算法(SPI)│
└────────────────┘
服务层的职责不是“转发一次 geom.buffer()”,而是把 JTS 的裸语义翻译成业务语义:坐标系与单位、精度与容差、异常与降级、结果裁剪与分页。
22.8.2 用接口 + SPI 做可插拔算法
假设你有一个“面积统计”能力,不同项目算法不同(平面面积 / 球面近似 / 投影面积)。定义接口:
package com.example.geo.spi;
import org.locationtech.jts.geom.Geometry;
/**
* 面积计算策略。实现类通过 {@link java.util.ServiceLoader} 加载。
*/
public interface AreaCalculator {
/**
* 策略标识,全局唯一。
*
* @return 策略名,如 "planar"、"spherical"
*/
String name();
/**
* 计算几何面积。
*
* @param geom 输入几何,非 null
* @return 面积,单位由实现约定
*/
double area(Geometry geom);
}
提供一个平面实现:
package com.example.geo.impl;
import org.locationtech.jts.geom.Geometry;
import com.example.geo.spi.AreaCalculator;
/**
* 平面面积:直接使用 JTS 的笛卡尔面积。
* <p>
* 注意:当坐标系是经纬度(EPSG:4326)时,结果的单位是“平方度”,
* 业务上通常没有意义,需要先投影或改用球面近似。
*/
public class PlanarAreaCalculator implements AreaCalculator {
public String name() {
return "planar";
}
public double area(Geometry geom) {
if (geom == null || geom.isEmpty()) return 0.0;
return geom.getArea();
}
}
用 ServiceLoader 装配(JDK 8 也支持,无需第三方):
package com.example.geo;
import java.util.HashMap;
import java.util.Map;
import java.util.ServiceLoader;
import org.locationtech.jts.geom.Geometry;
import com.example.geo.spi.AreaCalculator;
/**
* 面积服务:按名称选择策略,缺省用 planar。
*/
public class AreaService {
private final Map<String, AreaCalculator> calculators = new HashMap<String, AreaCalculator>();
public AreaService() {
for (AreaCalculator c : ServiceLoader.load(AreaCalculator.class)) {
calculators.put(c.name(), c);
}
}
public double area(String strategy, Geometry geom) {
AreaCalculator c = calculators.get(strategy);
if (c == null) {
throw new IllegalArgumentException("unknown strategy: " + strategy
+ ", available = " + calculators.keySet());
}
return c.area(geom);
}
public Map<String, AreaCalculator> available() {
return calculators;
}
}
注册文件放在 src/main/resources/META-INF/services/com.example.geo.spi.AreaCalculator,内容是实现类的全限定名:
com.example.geo.impl.PlanarAreaCalculator
这套结构的好处:新增算法只要加一个实现类 + 一行注册文件,服务层代码零改动;测试时可以直接 new 一个实现注入,不必启动容器。
22.8.3 服务层的“三条铁律”
| 铁律 | 原因 | 反例 |
|---|---|---|
| 入口做参数校验 | JTS 对非法输入的行为是抛 IllegalArgumentException 或返回 NaN,语义不统一 |
直接把空几何传进 buffer,得到 POLYGON EMPTY 却以为是失败 |
| 单位与坐标系显式化 | JTS 不含 CRS,距离/面积的单位完全取决于输入坐标 | 对 EPSG:4326 的几何调 buffer(100),以为单位是米 |
| 异常翻译成业务错误码 | TopologyException 对上层是无意义的栈信息 |
直接把 JTS 异常抛给 HTTP 客户端 |
第 3 条尤其重要:JTS 的 TopologyException 消息里带坐标(如 found non-noded intersection between LINESTRING (x1 y1, x2 y2) and ...),直接返回给前端等于泄露内部结构。标准做法是包一层 GeoException,带业务码 + 原始坐标(进日志,不进响应)。
22.9 在上层框架中集成扩展
22.9.1 一个重要澄清:GeoTools 没有 GeometryOperation
网上有些文章写“在 GeoTools 中扩展 JTS 的 GeometryOperation”,这是把两个库的概念混了。事实是:
org.locationtech.jtstest.geomop.GeometryOperation只存在于 JTS 的测试模块(jts-tests),它的用途是跑 XML 测试用例,与业务框架无关。- GeoTools 的几何能力直接构建在
jts-core之上,没有GeometryOperation这个接口,也没有对应的扩展点。 - 想在 GeoTools 里加“新几何操作”,正确的挂载点是 GeoTools 的 Process API(
org.geotools.process.Process与ProcessFactory),或在业务层自己包一个服务类(见 22.8)。
把 JTS 的函数类硬塞进 GeoTools 不会报错(它们只是普通静态方法),但也不会被任何 GeoTools 机制识别——函数级扩展只对 JTS 自己的 TestBuilder/TestRunner 有效。
22.9.2 三条现实的集成路线
| 目标 | 路线 | 需要写的代码 |
|---|---|---|
| 给 GeoTools 加可被 GeoServer 调用的空间算子 | GeoTools Process API + ProcessFactory |
实现 Process,注册 META-INF/services |
| 在业务代码里复用自定义几何算法 | 普通服务类 + 依赖 jts-core |
静态工具类或 Spring Bean |
| 让自定义算法可被 JTS 测试体系验证 | JTS 函数类 + -geomfunc |
XxxFunctions 静态方法类 |
| 想改变 JTS 内部执行策略 | 系统属性开关(jts.overlay、jts.relate) |
无代码,加 JVM 参数 |
这张表的核心含义:JTS 的函数机制是“测试工具链”的一部分,不是“应用集成框架”。把它用在调试与回归上收益最大;生产集成应该走服务封装或上层框架自己的扩展体系。
22.9.3 GeoTools Process 的最小骨架
如果确实需要在 GeoTools/GeoServer 里暴露一个空间算子,形态是这样的(只列出与本节主题相关的结构,具体依赖版本按你的 GeoTools 版本调整):
package com.example.geo.process;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.process.factory.DescribeParameter;
import org.geotools.process.factory.DescribeProcess;
import org.geotools.process.factory.DescribeResult;
import org.geotools.process.factory.StaticMethodsProcessFactory;
import org.locationtech.jts.geom.Geometry;
/**
* 把 JTS 算法包装成 GeoTools Process,从而可被 GeoServer 的 WPS 调用。
*/
@DescribeProcess(title = "Snap To Grid", description = "Snaps geometry coordinates to a grid")
public class SnapProcess {
@DescribeResult(name = "result", description = "snapped geometry")
public Geometry execute(
@DescribeParameter(name = "geom", description = "input geometry") Geometry geom,
@DescribeParameter(name = "gridSize", description = "grid size", min = 0) Double gridSize) {
if (geom == null || geom.isEmpty() || gridSize == null || gridSize <= 0) {
return geom;
}
// 直接复用 22.3 里写的 JTS 函数
return com.example.jtsext.SnapFunctions.snapToGrid(geom, gridSize);
}
/**
* 工厂:GeoTools 通过它发现 Process。
*/
public static class Factory extends StaticMethodsProcessFactory<SnapProcess> {
public Factory() {
super(SnapProcess.class, "example", "Snap", SnapProcess.class);
}
}
}
关键点:Process 层只做协议适配,算法仍然写在纯 JTS 的静态方法里。这样同一份算法可以同时被 TestBuilder 调试、被单测覆盖、被 WPS 调用,不会出现“GUI 里对、服务里错”的分裂。
22.9.4 依赖与类加载的三个坑
jts-tests不要进生产包。它带jdom2、commons-lang3等测试依赖,体积不小,而且GeometryOperation那套东西生产用不上。用provided作用域或<exclusions>排除。- 同名类冲突。老项目里可能同时存在
com.vividsolutions.jts.*(1.13 之前)与org.locationtech.jts.*。两者包名不同不会冲突,但几何对象不能互转,混用会出现ClassCastException。 - ServiceLoader 与 OSGi。
jts-core是 OSGi bundle(packaging=bundle),在 OSGi 容器里ServiceLoader的行为与普通 classpath 不同,需要显式配置META-INF/services的可见性。
22.10 常见问题与避坑
22.10.1 函数注册相关
Q:-geomfunc 加了类,但函数面板里没有。
A:按顺序检查四点——类是否在 classpath 上(ClassNotFoundException 会有提示)、方法是否 public static、第一个形参是否 Geometry 或其子类、类名是否拼错(-geomfunc 是全限定名,不是简单类名)。
Q:函数出现了,但参数名全是 Arg 0。
A:参数名默认是合成的,因为 Java 反射拿不到源码里的形参名(除非编译时加 -parameters)。想要可读的参数名就加 @Metadata(title = "...")。
Q:我的 buffer 覆盖了官方的 buffer,结果其他用例全挂了。
A:同名同参数个数的函数,后注册的会覆盖先注册的(sortedFunctions.put 直接覆盖)。自定义函数务必起独立名字,如前缀 myBuffer,不要和官方函数同名。
Q:同一个类里有的方法能出现在面板,有的不能。
A:只有 public static 且第一个形参是 Geometry 的方法会被包装。private、非静态、第一个参数是 String/double 的方法都会被静默跳过——静默是这里最坑的地方,没有警告。
22.10.2 参数与返回类型相关
Q:函数参数是 Object,XML 里传了字符串但运行报转换失败。
A:ArgumentConverter 只认 boolean/int/double/String 四种目标类型。形参声明成 Object 时 destClass.isAssignableFrom(String.class) 为 true,其实能过;但声明成自定义类型一定失败。能收窄就收窄。
Q:返回 void 的函数会怎样。
A:returnType 为 void,Geometry.class.isAssignableFrom(void.class) 为 false,会被当作标量函数处理,最终在 GeometryFunctionOperation.invoke 里抛 JTSTestReflectionException: Unsupported result type: void。不要写返回 void 的函数。
Q:返回 Integer(包装类)和返回 int 有区别吗。
A:有。GeometryFunctionOperation 里判断的是 func.getReturnType() == int.class,Integer.class 不等于 int.class,于是走不到 IntegerResult 分支,直接抛异常。标量返回值一律用基本类型。
Q:二元函数(A + B)的第二个几何怎么声明。
A:第二个形参声明为 Geometry 即可,框架会自动从 <b> 或 TestBuilder 的 B 输入框取。若允许为空,加 @Metadata(isRequired = false)。
22.10.3 Transformer 相关
Q:transformLineString 里返回的线只有 1 个点,报错。
A:JTS 的 LineString 至少 2 个点。无法构造合法线时返回 null,框架会丢弃该子几何。Javadoc 明确写了:transformXxx 方法返回 null 表示“不产生结果”。
Q:改了 Polygon 的洞,结果类型变成了 GeometryCollection。
A:这是预期行为。子几何类型变化时,父几何类型会自动降级为 GeometryCollection,除非你覆盖 transformMultiPolygon 并自行重组。要保留 MultiPolygon,就在子类里显式处理。
Q:Transformer 输出丢了 SRID。
A:如果你在子类里 new GeometryFactory() 而不是用 factory 字段,SRID 会丢。始终用继承来的 factory。
Q:Transformer 会不会修改输入。
A:默认行为是深拷贝坐标(复制到新数组),不会修改输入。但如果你在 transformXxx 里直接改传入的 Coordinate 对象,仍然会污染输入——因为 Coordinate 是可变对象。在 Transformer 里永远 new 新坐标。
22.10.4 部署与版本相关
Q:本地 TestBuilder 里能看到我的函数,服务器上跑 TestRunner 却报找不到。
A:classpath 差异。TestRunner 的 -geomfunc 用的是 JTSTestRunnerCmd.class.getClassLoader(),确认 jar 真的在 -cp 里,而不是只在 IDE 的 module path 里。
Q:JTS 1.17 上能用的扩展,升到 1.20 报错。
A:1.20 引入了 RelateNG、CoverageValidator 等新类,并调整了一批 API。扩展代码如果依赖内部包(org.locationtech.jts.geomgraph 这类),跨版本很容易挂。扩展只依赖公开稳定 API(geom、operation.overlayng、geom.util)才安全。
Q:能不能把 lab 的类直接抄进业务代码。 A:可以,但要看清许可证(EPL 2.0 / EDL 1.0 双许可,保留 header 即可)并且评估稳定性——lab 里的类随时可能改签名或被删。抄之前先确认它依赖的 core API 在你的 JTS 版本里存在。
22.10.5 设计层面的三个反模式
- 把业务规则塞进函数类:函数类应该只做几何,不查数据库、不发 HTTP。否则无法在 TestBuilder 里独立调试,也写不出纯单测。
- 一个函数干太多事:TestBuilder 的下拉按“一个函数一个动作”设计。把“吸附 + 修复 + 统计”合成一个函数,用户在 GUI 里就没法只做其中一步。
- 用函数机制做生产 API:函数类是测试工具链的一部分,没有版本兼容承诺、没有线程安全保证。生产要对外的能力应该走服务层(22.8)。
22.11 本章小结
- JTS 有三条扩展路线:函数级(
GeometryFunction)、操作级(GeometryOperation)、结构级(GeometryTransformer/GeometryMapper),分别对应“加功能”“改执行”“改结构”。 - 函数级扩展的成本极低:写一个
XxxFunctions类,方法为public static且第一个形参是Geometry,其余参数限boolean/int/double/String/Geometry,用-geomfunc注册即可上 TestBuilder。 - 框架用
StaticMethodGeometryFunction反射包装静态方法,category 取自类名去掉Functions后缀,因此类名要以XxxFunctions结尾。 @Metadata可改善可读性:description进提示框,title改参数名,isRequired=false让第二个几何可选。GeometryOperation位于jts-tests模块而非jts-core,用于接管测试执行;内置Logging/Tee/Prepared三个装饰器是最好的学习范本。- XML 里的
<geometryOperation>与命令行-geomop都能挂载自定义操作,命令行优先;加载器要求实现类有 public 无参构造器。 GeometryTransformer是final的模板方法框架,只覆盖transformXxx;返回null表示丢弃,factory字段保证 SRID 与精度一致。modules/lab的ClipFunctions、IteratedOverlayFunctions是高质量范本,其中“包络短路”“多实现并列”“整型精度参数”三个模式可直接复用。- 生产集成应该做服务封装(接口 +
ServiceLoaderSPI 或框架 Process API),不要把测试函数类当生产 API。 - 一个常见误解要澄清:GeoTools 没有
GeometryOperation接口;在 GeoTools/GeoServer 里加空间算子应走 Process API。
相关资源
- JTS 官方仓库:https://github.com/locationtech/jts
- JTS TestBuilder 用户指南(PDF):https://github.com/locationtech/jts/blob/master/doc/JTS%20TestBuilder%20%26%20TestRunner%20User%20Guide.pdf
- JtsOp 命令行指南:https://github.com/locationtech/jts/blob/master/doc/JTSOp.md
- 函数实现范本(app 模块):https://github.com/locationtech/jts/tree/master/modules/app/src/main/java/org/locationtech/jtstest/function
- lab 模块(实验函数与算法):https://github.com/locationtech/jts/tree/master/modules/lab/src/main/java/org/locationtech/jtslab
- 本系列第 21 章(源码编译、测试与贡献):https://znlgis.github.io/gis/tutorial/jts/第21章-源码编译测试与贡献/
- 本系列第 23 章(JTS 与上下游技术栈集成):https://znlgis.github.io/gis/tutorial/jts/第23章-JTS与上下游技术栈集成/
- GeoTools 官方文档:https://docs.geotools.org/