用纯 Java 读写 Esri FileGDB:java-openfilegdb 使用指南与依赖解析
项目地址:https://github.com/lizhiziwang/java_openfilegdb.git
一个不依赖 GDAL、不碰 JNI 的纯 Java 库,直接按字节解析
.gdb。本文讲清它怎么用、依赖什么、以及几个容易踩的资源管理坑。
0. 太长不看
- 它是 GDAL
OpenFileGDB驱动的纯 Java 重新实现:不链接 GDAL、不用 JNI/JNA、不依赖 Esri SDK,格式细节全用java.nio.ByteBuffer逐字节解析。 - 对外 API 对齐 OGR:
Dataset → Layer → Feature → Field / Geometry。 - SQL 引擎用Apache Calcite 1.38.0,
executeSql返回的就是一个可读的GdbLayer(对标 GDALExecuteSQL()返回OGRLayer*)。 - 读是流式的:
SELECT / WHERE / LIMIT默认不占额外内存,2 万行全取 GC 后零增长。
1. 背景:为什么需要它
Esri File Geodatabase(.gdb目录)是 ArcGIS 的私有矢量格式。过去在 Java 里读它基本两条路:
- 通过 JNI 调 GDAL 的 OpenFileGDB 驱动 —— 要带 native 库,部署痛苦;
- 用 Esri 官方 FileGDB API(C++ SDK)—— 同样要 native,且有许可限制。
java-openfilegdb是 GDALogr/ogrsf_frmts/openfilegdb/C++ 驱动的纯 Java 重新实现。它适合:
- 不能引入 native 依赖的运行环境(云函数、部分容器 / Android 受限场景、纯 JVM 微服务);
- 想把
.gdb读取能力直接编进 Java 应用,又不想维护 GDAL 交叉编译的工程。
能力边界(刻意与 GDAL 对齐的限制):
- 只读矢量:feature class 与 table;不读栅格目录。
- 写只到版本 3:不写 Z/M、不写空间索引(
.spx)、不写域 / 子类型 / 关系类 / 要素数据集。 - SQL只读:
SELECT / WITH / VALUES / TABLE / EXPLAIN;不做ST_*空间函数、不做DROP TABLE写命令。
2. 依赖关系
2.1 外部依赖(Maven)
| 依赖 | 版本 | 作用域 | 说明 |
|---|---|---|---|
org.slf4j:slf4j-api | 2.0.13 | compile | 日志门面,实现由你提供(logback / log4j2 / slf4j-simple 任选) |
org.apache.calcite:calcite-core | 1.38.0 | compile | SQL 引擎(executeSql用) |
org.junit.jupiter:junit-jupiter | 5.10.2 | test | 单元测试 |
org.slf4j:slf4j-simple | 2.0.13 | test | 测试用日志实现 |
关键点:
- 跑 SQL 必须带上
calcite-core及其传递依赖(Calcite 会拉入calcite-linq4j、avatica-core、jackson-*、janino等)。在 Hadoop / Spark / Flink 这类已带旧版 Calcite 的环境里,注意版本冲突,必要时用 shade 重定位。 - Calcite 在运行时会用Janino(一个纯 Java 实现的编译器)动态生成 Java 字节码执行表达式。Janino 不涉及 JNI,因此不破坏本库的"纯 Java"硬约束。
- 运行环境要求Java 8(源码 / 字节码 target 8)。
Maven 坐标(当前是1.0.0-SNAPSHOT,先本地mvn clean install):
<dependency><groupId>com.gdal</groupId><artifactId>java-openfilegdb</artifactId><version>1.0.0-SNAPSHOT</version></dependency><!-- 运行时还需显式提供: --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>2.0.13</version></dependency><dependency><groupId>org.apache.calcite</groupId><artifactId>calcite-core</artifactId><version>1.38.0</version></dependency>SLF4J 只给门面,别忘了再选一个日志实现;Calcite 是重依赖,生产打包请确认它进了最终运行 classpath。
2.2 内部模块依赖图
包结构(包名前缀com.gdal.openfilegdb):
dataset ──► layer ──► table ──► feature / index / srs / io / util / exception sql ──► dataset / layer / feature (把 OpenFileGdbLayer 暴露给 Calcite) demo ──► 以上全部(示例)依赖方向图(A → B表示 A 依赖 B):
几条架构铁律(也是阅读源码时的导航):
table层不认识layer/dataset—— 它只管单个.gdbtable的字节;往上组装交给layer/dataset。io/util/exception是叶子层,不反向依赖任何上层(exception被各处抛出,util提供通用工具)。- 所有公共资源类(
OpenFileGdbDataset/GdbTable/GdbTableIndex/GdbSystemCatalog/GdbSpatialIndex/GdbSqlLayer/GdbSqlResult)都实现AutoCloseable,文档示例一律 try-with-resources。 - IO 一律走
FileChannel定位读写(不共享文件游标):读可并发;写必须单线程串行。
2.3 与 GDAL 的关系
它不是对 GDAL 的 JNI 封装,而是行为对齐的重新实现。遇到格式细节分歧时,以工程里本地保留的reference/cpp/(上游 GDAL C++ 源码,只读、不入库)为准;跨语言实现的正确性也已和真实ogrinfo/ogr2ogr对照验证过(见 §7)。
3. 快速开始
importcom.gdal.openfilegdb.dataset.OpenFileGdbDataset;// 打开已有 .gdbtry(OpenFileGdbDatasetds=OpenFileGdbDataset.open(java.nio.file.Paths.get("D:/data/2024年国土行政区划.gdb"))){System.out.println("图层: "+ds.getLayerNames());}// 新建一个 .gdb(写)try(OpenFileGdbDatasetds=OpenFileGdbDataset.create(java.nio.file.Paths.get("out/write-demo.gdb"))){// ... 建图层、写要素(见 §4.3)ds.flush();// 落盘}4. 核心用法
4.1 打开与遍历图层(OGR 风格)
API 命名直接照抄 OGR:Dataset → Layer → Feature → Field / Geometry。
try(OpenFileGdbDatasetds=OpenFileGdbDataset.open(path)){intn=ds.getLayerCount();// 用户图层数(系统表不算)for(inti=0;i<n;i++){OpenFileGdbLayerlayer=ds.getLayer(i);// 等价 ds.getLayer(name)System.out.println("图层: "+layer.getName());System.out.println("几何: "+layer.getGeometryType());System.out.println("要素数: "+layer.getFeatureCount());System.out.println("范围: "+java.util.Arrays.toString(layer.getExtent()));// 字段定义for(GdbFieldf:layer.getFields()){System.out.printf(" %s %s 长度=%d 可空=%s%n",f.getName(),f.getTypeName(),f.getWidth(),f.isNullable());}// 逐条遍历:单游标,支持 for-each(对应 OGR GetNextFeature / ResetReading)for(GdbFeaturefeat:layer){longoid=feat.getFID();// OBJECTIDStringname=feat.getFieldAsString("NAME");GdbGeometrygeom=feat.getGeometryRef();// 几何(惰性解码)if(geom!=null)System.out.println(oid+" "+name+" "+geom.toWkt());}}}取值捷径:getFieldAsString / getFieldAsInteger / getFieldAsInteger64 / getFieldAsDouble / getFieldAsBoolean,以及isFieldSet(name)。空间参考:layer.getSpatialReference().getWkid()。
4.2 空间过滤(精确几何相交)
两种方式,语义不同:
// (A) 流式:设置过滤矩形后逐条拉 —— 内存 O(1)layer.setSpatialFilterRect(xmin,ymin,xmax,ymax);GdbFeaturef;while((f=layer.getNextFeature())!=null){/* ... */}layer.clearSpatialFilter();// (B) 急切:一次返回命中列表(全驻留)List<GdbFeature>hits=layer.search(xmin,ymin,xmax,ymax);要点:
- 过滤判据是
Intersects(精确几何相交),不是包围盒相交 —— 凹多边形、带洞面都按真实几何判断,因此和ogrinfo -spat的结果逐条一致。 - 有
.spx空间索引时先粗筛候选行号;索引缺失或损坏自动退化为全表扫描,行为不变。 getFeature(oid)绕过空间过滤(与 OGR 一致),只看 FID。
4.3 写数据
List<GdbField>fields=newArrayList<>();fields.add(newGdbField("NAME",FieldType.STRING).setLength(64));fields.add(newGdbField("POP",FieldType.INT32));try(OpenFileGdbDatasetds=OpenFileGdbDataset.create(outDir.resolve("cities.gdb"))){OpenFileGdbLayerlayer=ds.createLayer("城市",SpatialReference.fromEpsg(4326),GeometryType.POINT,fields);GdbFeaturef=newGdbFeature();f.setAttribute("NAME","北京").setAttribute("POP",21893000);f.setGeometry(GdbGeometry.point(116.4074,39.9042));// 点几何longoid=layer.createFeature(f);// OBJECTID 自动分配ds.flush();// 必须 flush 才真正落盘}- 建图层时只给业务字段;
OBJECTID与Shape由库自动补在前面。 - 非空间表用
ds.createTable(name, fields)。 - 只读打开的库上调用写方法会抛
GdbWriteException。 - 写出的库可被 GDAL 直接识别:
ogrinfo -al -so cities.gdb。
4.4 SQL 查询(重点)
executeSql对标 GDALExecuteSQL():返回一个GdbLayer,可以像普通图层一样getLayerDefn()/getNextFeature()/getFID()/getGeometryRef()。
try(OpenFileGdbDatasetds=OpenFileGdbDataset.open(path)){// 方式一:当图层读(对标 GDAL ExecuteSQL 返回 OGRLayer)try(GdbLayerrs=ds.executeSql("SELECT NAME, POP FROM \"城市\" WHERE POP > 20000000 ORDER BY POP DESC")){System.out.println("层名="+rs.getName());// 固定 "SELECT",与 GDAL 一致while((GdbFeaturef=rs.getNextFeature())!=null){System.out.println(f.getFID()+" "+f.getFieldAsString("NAME"));}}// 自动 releaseResultSet// 方式二:拿原始 Object[] 游标(聚合查询更顺手)try(GdbSqlResultrows=ds.executeSqlRows("SELECT COUNT(*) AS n FROM \"城市\"")){for(Object[]row:rows)System.out.println(row[0]);}}关键语义:
- 行 → 要素映射:几何列 →
feature.getGeometry()(不进属性表);OBJECTID列 →feature.getFID()(也不进属性表);其余列按名进属性表,getField("列名")取。无 OBJECTID 时 FID 退化为 1…n。 - 流式 vs 物化:
SELECT / WHERE / LIMIT与聚合全程流式(实测 2 万行全取 GC 后无增长);ORDER BY / DISTINCT / GROUP BY按 SQL 语义物化(与 GDAL 文档同款取舍)。 - 必须释放资源:
GdbSqlLayer持有 Calcite JDBC 连接,一定要try-with-resources 或ds.releaseResultSet(rs),否则连接泄漏。 - 带空间过滤的 SQL:
ds.executeSql(sql, bbox)/ds.executeSqlRows(sql, bbox),等价于 GDALExecuteSQL(pszSQL, poSpatialFilter)—— 会把矩形下推到源图层走.spx索引。 - 性能贴士:
SELECT *会把几何列也解码出来(虽然没用到)。只想读属性就SELECT 列名,Calcite 会把投影下推,几何 blob整个不读不解码,更快更省。 - 不支持:写语句(
DROP TABLE/DELLAYER:)、ST_*空间函数、fid伪列(用OBJECTID)。
5. 资源管理与其他坑
- 优先 try-with-resources:
OpenFileGdbDataset、GdbSqlLayer、GdbSqlResult都是AutoCloseable。OpenFileGdbLayer的资源随 Dataset 释放;而 SQL 结果层(GdbSqlLayer/GdbSqlResult)握有自己的 JDBC 连接,必须单独关闭。 - SQL 结果必须释放:
executeSql返回的图层不是普通图层,漏掉releaseResultSet会泄漏连接。 - 单游标语义:
getNextFeature()/for-each是单游标(对齐 OGR)。嵌套遍历同一图层前要先resetReading()。 - 字符集:SQL 字面量按 UTF-8 解析(内部已处理,无需你设系统属性)。
- 日期空值 / 秒级吸附等格式怪癖库已按 GDAL 规则处理,直接用
LocalDateTime读日期时间字段即可。
6. 性能与内存
- 流式读取:每行 O(1) 常驻(只活着"当前 feature" +
getLayerDefn()内部预取缓存的一行)。 - 几何惰性解码:
getGeometry()第一次被调用时才解 blob;SELECT投影只引用属性列时连几何 blob 都不碰。 - 唯一 O(n) 常驻结构是
.gdbtablx偏移表(8 字节 / 条);空间过滤的候选行号数组同样可忽略。 - 对照实测:21,217 行
SELECT OBJECTID, Shape全取,GC 后内存零增长。
7. 与 GDAL 行为对照(已验证)
| 场景 | 本库 | GDAL (ogrinfo / ogr2ogr) |
|---|---|---|
| 村行政区划 空间过滤命中数 | 6094 | 6094(逐 OBJECTID 一致) |
| 乡行政区划 空间过滤 | 2649 / 604 | 2649 / 604 |
SELECT ... WHERE dcmj > 1e6命中 | 5712 | 5712 |
COUNT(*)/SUM/MIN/MAX/DISTINCT | 一致 | 一致 |
8. 小结
java-openfilegdb让你在纯 JVM里读写 Esri FileGDB,API 对齐 OGR、SQL 返回可当图层读的结果集,读路径流式且内存稳定。引入时记住三件事:
- 带上 slf4j 实现 + calcite-core(Calcite 是重依赖,注意环境冲突);
- SQL 结果用完释放(try-with-resources 或
releaseResultSet); - 大表用投影列(
SELECT 列名)避免无谓的几何解码。
- 源码包:
com.gdal.openfilegdb - 构建:
mvn clean package(83 测试,0 失败) - 示例:
demo.GdbReadDemo/GdbWriteDemo/GdbSqlDemo