第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)

由此得出三个硬约束:

  1. 必须是 public static。非静态方法会被 createFunctions 直接跳过(判断条件是 Modifier.isStatic(mod) && Modifier.isPublic(mod))。
  2. 第一个形参必须是 Geometry,否则 isGeometryFunction 返回 false,方法被忽略;若强行 register 会在 Assert.isTrue 处失败。
  3. 其余参数只能是 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) { ... }
  // ...
}

三个关键设计:

  1. transform 是 final 的,你只能覆盖 transformXxx 系列——这保证了分派逻辑不会被改坏。
  2. 每个 transformXxx 返回 null 表示“丢弃该子几何”,父级会自动跳过(pruneEmptyGeometry 默认 true)。
  3. 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 行里能提炼出四条可复用的模式:

  1. 同一需求的多个实现并列提供(clipPoly vs clipByIntersection),方便在 TestBuilder 里对比精度与性能。
  2. 包络短路:rectEnv.contains(geom.getEnvelopeInternal()) 命中时直接返回 geom.copy(),省掉昂贵叠加。这个优化在真实业务里收益极大,值得抄。
  3. 返回 null 表示“无结果”:clipByIntersectionOpt 在不相交时返回 null,TestBuilder 会显示为空,效果等同于“没有输出”。这与 SpreaderGeometryFunction 会过滤 null/empty 的规则一致。
  4. 精度模型作为可选参数: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 依赖与类加载的三个坑

  1. jts-tests 不要进生产包。它带 jdom2、commons-lang3 等测试依赖,体积不小,而且 GeometryOperation 那套东西生产用不上。用 provided 作用域或 <exclusions> 排除。
  2. 同名类冲突。老项目里可能同时存在 com.vividsolutions.jts.*(1.13 之前)与 org.locationtech.jts.*。两者包名不同不会冲突,但几何对象不能互转,混用会出现 ClassCastException。
  3. 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 设计层面的三个反模式

  1. 把业务规则塞进函数类:函数类应该只做几何,不查数据库、不发 HTTP。否则无法在 TestBuilder 里独立调试,也写不出纯单测。
  2. 一个函数干太多事:TestBuilder 的下拉按“一个函数一个动作”设计。把“吸附 + 修复 + 统计”合成一个函数,用户在 GUI 里就没法只做其中一步。
  3. 用函数机制做生产 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 是高质量范本,其中“包络短路”“多实现并列”“整型精度参数”三个模式可直接复用。
  • 生产集成应该做服务封装(接口 + ServiceLoader SPI 或框架 Process API),不要把测试函数类当生产 API。
  • 一个常见误解要澄清:GeoTools 没有 GeometryOperation 接口;在 GeoTools/GeoServer 里加空间算子应走 Process API。

相关资源