事情开始于一个很普通的工作需求:业务表里要有地理位置字段。数据库用的是 PostgreSQL + PostGIS,持久层用的是 jOOQ——一个写 SQL 写得很舒服的类型安全 ORM。看起来没什么问题,直到代码生成跑完,我盯着生成的表对象发呆:空间字段被映射成了 Object,想读写一个 Point 都要自己和字符串搏斗。

这篇文章记录 jooq-postgis 这个小项目要解决的问题、设计取舍和用法。它现在已发布到 Maven Central(top.yunitytech.maven:jooq-postgis),Java 8+ / jOOQ 3.14+ / PostGIS 3.x 可用。

问题:类型安全在空间字段这里断了

jOOQ 的强项是 codegen:从数据库 schema 生成类型安全的表和字段,SQL 写错编译期就报错。但 PostGIS 的 geometry / geography 列不在此列——默认生成出来就是一个没有语义的类型,你得:

我想要的其实很简单:空间字段直接生成成 JTS 的 org.locationtech.jts.geom.Geometry,读写零手工解析,原生函数直接调用,依赖越轻越好。没有就自己写一个,于是有了 jooq-postgis。

方案:两个 binding + 一个独立编解码器

jooq-postgis 是一套轻量、production-grade 的 jOOQ 空间绑定,直接映射到 JTS Geometry,专为 jooq-codegen-maven 自动生成类型安全空间字段设计。几个关键取舍:

1. 纯 JTS,零 GeoTools

不引入 gt-main,不需要 OSGeo 仓库,100% Maven Central,启动快、依赖干净。

2. geometry 与 geography 分成两个 binding

这是我觉得最重要的语义设计。二者一个是平面笛卡尔空间,一个是测地(球面)空间,混用一个 binding 很容易写错。jooq-postgis 拆成:

这样语义不混淆,原生函数(ST_Distance、ST_DWithin、ST_Intersects)不需要手工转型就能调。

3. 全维度支持:2D / 3D / 3DM / 4D

标准平面 (XY)、带高程 (XYZ)、带度量 (XYM,比如 GPS 时间戳、遥感数据),以及卫星轨迹这种 POINT ZM 四维 (XYZM),都支持往返无损。

4. JDBC 层面做对细节

5. PostgisCodec:脱离 jOOQ 也能用

编解码器是独立的,自定义 JDBC、REST 接口、队列消费里都能直接用:

// PGobject / EWKB Hex / EWKT / byte[] → JTS Geometry
Geometry geom = PostgisCodec.from(databaseObject);

// JTS Geometry → PostGIS 表示(大端 EWKB Hex,含 SRID)
String repr = PostgisCodec.toSpatialRepresentation(geom);

快速上手

两步:给 codegen 插件加依赖并配 forcedTypes,然后像普通字段一样读写。

1. 配置 jOOQ 代码生成

<forcedTypes>
    <!-- 平面 geometry 列 -->
    <forcedType>
        <userType>org.locationtech.jts.geom.Geometry</userType>
        <binding>top.yunitytech.maven.jooq.binding.PostgisGeometryBinding</binding>
        <includeTypes>(?i:geometry)</includeTypes>
    </forcedType>
    <!-- 球面 geography 列 -->
    <forcedType>
        <userType>org.locationtech.jts.geom.Geometry</userType>
        <binding>top.yunitytech.maven.jooq.binding.PostgisGeographyBinding</binding>
        <includeTypes>(?i:geography)</includeTypes>
    </forcedType>
</forcedTypes>
jOOQ 3.15+ 自带原生空间类型,生成器会把 geometry 列解析成 org.jooq.Geometry——记得给两个 <forcedType> 都加上 <genericBinding>true</genericBinding>,生成的代码才能编译通过;jOOQ 3.14 则必须省略这个元素。

2. 读写空间数据

GeometryFactory gf = new GeometryFactory();

// 插入带 SRID 的 2D 点
Point beijing = gf.createPoint(new Coordinate(116.4074, 39.9042));
beijing.setSRID(4326);
dsl.insertInto(SPATIAL_RECORD)
   .set(SPATIAL_RECORD.GEOM, beijing)
   .execute();

// 插入 4D 点(卫星轨迹 XYZM),读回时维度完整保留
Point obs = gf.createPoint(new CoordinateXYZM(116.4, 39.9, 500000.0, 1695888000.0));
obs.setSRID(4326);
// ... 写入后再读出,coord.getZ() == 500000.0, coord.getM() == 1695888000.0

3. 原生球面距离,无需转型

geography 列绑定后,ST_Distance 直接返回米,ST_DWithin 直接做阈值判断:

// 球面距离(米)
Double distanceMeters = dsl.select(
        DSL.field("ST_Distance({0}, {1})", Double.class, A.GEOG, B.GEOG)
    ).from(A).crossJoin(B)
    .fetchOne(0, Double.class);

// 200 km 内?
Boolean isNearby = dsl.select(
        DSL.field("ST_DWithin({0}, {1}, 200000)", Boolean.class, A.GEOG, B.GEOG)
    ).from(A).crossJoin(B)
    .fetchOne(0, Boolean.class);

踩过的坑:边界语义

做一个「老实」的编解码器,最难的是把边界情况和 PostgreSQL / PostGIS 的语义对齐。几个印象深的:

写在最后

这个项目的集成测试跑在真实的 PostgreSQL + PostGIS 上(CI 是 JDK 17/21 × jOOQ 3.14/3.19 的矩阵),每个边界语义都有测试兜底。如果你的项目也是 jOOQ + PostGIS 的组合,希望它能帮你省掉当初我摸的那些黑:

github.com/upowerman/jooq-postgis · Maven Central:top.yunitytech.maven:jooq-postgis