上个月接了一个绕不开的数据流转任务:每天要把业务系统同步下来的几十个gdb文件批量转成shapefile,同时还要把另一批shp按图层合并进新的gdb。桌面GIS软件一个个导,图层少的时候还能忍,几十个图层、几十个字段、每天定时重复,手工操作就成了纯粹的消耗。最后我在Java服务里引入了GDAL,写了一个通用数据导出工具类,gdb导出、shapefile导出、几何数据导出都走同一套方法。这篇把环境搭建、工具类设计、实现细节和翻车点完整记下来,给打算在Java工程里直接用GDAL做批量导出的同学做个参考。
1. 为什么在Java项目里用GDAL做数据导出,而不是靠桌面工具或纯Java方案
先说结论:如果你的数据处理任务是需要定时、批量、无人值守地跑在服务器上的,那桌面工具天然不合适。人不能每天半夜起来点鼠标,也不应该把几十个gdb手工拖进软件里一次一次转。GDAL能做到的是,把格式转换变成一个可编程的管道,你只需要定义“从哪里读、写到哪里、按什么规则处理”,剩下的交给库本身。
GDAL这个名字容易让人以为它只处理栅格影像,实际上它分成两大块:栅格部分叫GDAL,矢量部分叫OGR。矢量部分支持gdb、shapefile、GeoJSON、GeoPackage这些常见格式,数量级在几十种以上。Java工程里接入的其实是它的JNI绑定,底层跑的是C/C++原生库,Java层负责调用和收拢结果。这套结构的好处是稳定、快,而且格式支持跟着主库走,不用纯Java社区自己维护一套格式实现。
纯Java的地理数据方案不是没有,但遇到gdb这种格式就比较尴尬。gdb本身是个文件地理数据库,内部的存储结构在公开文档里写得不完整,很多纯Java实现只能读不能写,或者读出来字段、坐标系、几何类型和原始数据有出入。GDAL是图形数据领域公认的底层库,很多GIS平台自己都在用GDAL做读写,在格式兼容性上更有底气。所以我选择GDAL,核心原因就三个:格式全、能批量、可嵌入Java服务。
GDAL/OGR的数据访问模型也很简单,它把几乎所有矢量数据都抽象成四层结构:
- 驱动(Driver):负责识别和创建某种格式,比如“ESRI Shapefile”驱动、OpenFileGDB驱动;
- 数据源(DataSource):对应一个文件、一个目录或一个数据库连接;
- 图层(Layer):数据源里的一个逻辑图层,类似数据库表;
- 要素(Feature):图层里的一行,包含属性字段和几何对象。
理解这四层是写通用导出工具的前提。因为所谓导出,本质就是:遍历源数据源里的图层,逐个读要素,再写到目标数据源的对应图层。格式变了、驱动变了,这个流程骨架不变。
2. 环境搭建最容易翻车的三件事:Native库、版本匹配和GDAL_DATA
2.1 gdal.jar和原生库必须来自同一套构建
Java接入GDAL最坑人的第一步就是jar包和原生库版本对不上。GDAL的Java绑定不是纯Java实现,gdal.jar里是一堆JNI声明,真正干活的都在原生动态库里。Windows下是dll,Linux下是so,Mac下是dylib。你从一个地方下载了gdal.jar,又从另一个地方下载了原生库,分分钟给你抛UnsatisfiedLinkError、符号找不到之类的错误。
我的做法是:直接从一套GDAL发布包里同时取jar和对应原生库,尽量不混搭。工程里用一个lib目录,把gdal.jar作为普通jar引入,再把原生库放到能被JVM找到的位置。Windows下可以放到PATH里,或者直接把dll所在目录加到java.library.path;Linux下一般是设置LD_LIBRARY_PATH,或者在启动参数里写:
java -Djava.library.path=/opt/gdal/lib -jar exporter.jar不要用相对路径,服务器重启、目录调整之后特别容易出问题。我第一次在Linux服务器上部署时,就是因为java.library.path没指对,程序一打开数据源就报错,排查半天全是路径问题。
2.2 静态初始化、驱动注册和GDAL_DATA
GDAL和OGR在使用前要先注册驱动。Java绑定里对应的代码是这样:
import org.gdal.gdal.gdal; import org.gdal.ogr.ogr; public class GdalInitializer { private static volatile boolean initialized = false; public static synchronized void init() { if (initialized) { return; } gdal.AllRegister(); ogr.RegisterAll(); // 文件名按UTF-8处理,避免中文路径问题 gdal.SetConfigOption("GDAL_FILENAME_IS_UTF8", "YES"); initialized = true; } }这段代码建议放在静态块或程序启动时执行一次。AllRegister()和RegisterAll()会把所有驱动注册到内部驱动管理器中,后续通过驱动名获取驱动。
另一个绕不开的坑是GDAL_DATA环境变量。这个变量指向GDAL的数据目录,里面有proj.db、各种坐标系定义文件。服务器上没有配置GDAL_DATA时,打开数据源经常报类似PROJ: proj_create_from_database: Cannot find proj.db的错误。建议通过环境变量永久配置,而不是在代码里写死路径:
export GDAL_DATA=/usr/local/share/gdal如果确实需要在代码里指定,也可以设置:
gdal.SetConfigOption("GDAL_DATA", "/usr/local/share/gdal");但要注意,SetConfigOption这种方式受驱动初始化时机影响,还是环境变量最省心。
2.3 一条命令验证驱动是否齐全
环境搭完后,别急着跑正式代码,先用一小段代码验证当前GDAL到底支持哪些驱动。我用的是这种方式:
import org.gdal.gdal.gdal; import org.gdal.ogr.ogr; import org.gdal.ogr.Driver; GdalInitializer.init(); System.out.println("GDAL版本: " + gdal.VersionInfo("RELEASE_NAME")); System.out.println("驱动数量: " + ogr.GetDriverCount()); Driver gdbDriver = ogr.GetDriverByName("OpenFileGDB"); Driver shpDriver = ogr.GetDriverByName("ESRI Shapefile"); System.out.println("OpenFileGDB驱动: " + (gdbDriver == null ? "不存在" : gdbDriver.GetName())); System.out.println("Shapefile驱动: " + (shpDriver == null ? "不存在" : shpDriver.GetName()));这一步能快速排除“编译版本里没带某个驱动”的问题。不要跳过,我遇到过有人直接跑正式逻辑,最后发现当前GDAL根本没编译GDB写入驱动,白查半天。
3. 通用导出工具类的三段式抽象:DataSource、Layer、Feature
3.1 为什么说“数据源-图层-要素”三层结构是通用导出的核心
写通用工具类前,先理解目标格式之间的差异。用一个类比:数据源就像Excel工作簿,图层是工作表,要素是表中的一行,几何字段则是这行里的一个对象。gdb是一个目录当作工作簿,里面可以有多个表;shapefile不太一样,一个文件本身就是一个数据源,一张表放在里面,但这个表也能挂到某个目录数据源下。
所以通用导出的核心,是先“打开源数据源”,再“遍历图层”,最后“读要素写要素”。只要把这三个动作抽象出来,格式差异只体现在驱动选择和创建数据源的方式上,其余逻辑完全复用。
3.2 打开源数据与枚举图层的骨架代码
打开数据源用的ogr.Open的第一个参数是路径,第二个0表示只读。路径可以是gdb目录路径、shp文件路径、GeoJSON文件路径等。打开后逐层遍历:
import org.gdal.ogr.DataSource; import org.gdal.ogr.Layer; import org.gdal.ogr.ogr; DataSource srcDs = ogr.Open(srcPath, 0); if (srcDs == null) { throw new IllegalArgumentException("OGR无法打开数据源: " + srcPath + ", 错误信息: " + org.gdal.gdal.gdal.GetLastErrorMsg()); } for (int i = 0; i < srcDs.GetLayerCount(); i++) { Layer layer = srcDs.GetLayerByIndex(i); System.out.println("图层 " + i + ": " + layer.GetName() + ", 要素数=" + layer.GetFeatureCount() + ", 几何类型=" + layer.GetGeomType()); }这里有一个很容易忽略的点:GetGeomType()可能返回wkbUnknown,尤其是某些gdb图层或混合几何类型的图层。后面写目标图层时会需要几何类型,所以这个值不能盲信,必要时扫描前几十个要素的实际几何再决定。
3.3 工具类整体结构:导出入口按格式分发
工具类的入口我设计成按目标格式分发,方便以后扩展。整体结构可以这样理解:
public class GdalExporter { private GdalExporter() {} public static void exportAll(String srcPath, String outPath, String format) throws Exception { GdalInitializer.init(); DataSource srcDs = ogr.Open(srcPath, 0); if (srcDs == null) { throw new RuntimeException("无法打开源数据源: " + srcPath); } try { int layerCount = srcDs.GetLayerCount(); for (int i = 0; i < layerCount; i++) { Layer srcLayer = srcDs.GetLayerByIndex(i); if ("gdb".equalsIgnoreCase(format)) { // 一个GDB是单个数据源,所有图层写进去 } else if ("shp".equalsIgnoreCase(format)) { // 一个shp对应一个独立文件,每个图层单独创建数据源 } else { throw new IllegalArgumentException("不支持的导出格式: " + format); } } } finally { srcDs.delete(); } } }之所以把“按格式分发”放到入口层,是因为gdb和shp的数据源创建方式差异很大:gdb是一个目录数据源,多个图层共享;shp最好是每个图层创建一个独立数据源。如果强行做成一个方法,代码里全是if判断,反而不好维护。
4. GDB批量导出:驱动选择、创建数据源、写图层
4.1 OpenFileGDB和FileGDB驱动的区别
GDAL里跟gdb写入相关的驱动有两个,经常有人搞混。
| 驱动名 | 读取能力 | 创建/写入能力 | 依赖情况 | 适用建议 |
|---|---|---|---|---|
| OpenFileGDB | 完整读取 | 新版本支持创建和写入,能力较完整 | 驱动内置,无额外依赖 | 多数情况下优先使用,环境简单 |
| FileGDB | 完整读取 | 写入能力更完整 | 依赖专有FileGDB API,一般需要单独编译 | 遇到OpenFileGDB写不了的高级特性时可考虑 |
如果你用的GDAL版本较新,比如3.6以上,OpenFileGDB驱动基本可以满足日常gdb导出需求。注意,OpenFileGDB的写入在新版本中也有部分限制,例如某些高级数据类型可能不支持。我的做法是代码里优先尝试OpenFileGDB,找不到时再回退到FileGDB:
Driver gdbDriver = ogr.GetDriverByName("OpenFileGDB"); if (gdbDriver == null) { gdbDriver = ogr.GetDriverByName("FileGDB"); } if (gdbDriver == null) { throw new RuntimeException("当前GDAL没有可用的GDB写入驱动"); }4.2 创建GDB并写入图层:完整代码
创建GDB数据源时,路径可以直接给一个以.gdb结尾的目录。如果目录已存在,很多驱动会创建失败,所以输出路径最好保证不存在,或者先删除残留目录。
Driver gdbDriver = ogr.GetDriverByName("OpenFileGDB"); DataSource gdbDs = gdbDriver.CreateDataSource(gdbPath); if (gdbDs == null) { throw new RuntimeException("创建GDB失败: " + gdbPath + ", 错误: " + org.gdal.gdal.gdal.GetLastErrorMsg()); }图层创建的完整流程分四步:拿到源图层定义、在目标GDB中创建图层、复制字段定义、写要素。
private static void exportLayerToGdb(Layer srcLayer, DataSource gdbDs, String layerName) throws Exception { LayerDefn defn = srcLayer.GetLayerDefn(); SpatialReference srs = srcLayer.GetSpatialRef(); int geomType = srcLayer.GetGeomType(); // 1. 创建目标图层 Layer outLayer = gdbDs.CreateLayer(layerName, srs, geomType); // 2. 复制字段定义,连字段宽度和精度一起复制 for (int i = 0; i < defn.GetFieldCount(); i++) { FieldDefn srcFld = defn.GetFieldDefn(i); FieldDefn dstFld = new FieldDefn(srcFld.GetName(), srcFld.GetFieldType()); dstFld.SetWidth(srcFld.GetWidth()); dstFld.SetPrecision(srcFld.GetPrecision()); outLayer.CreateField(dstFld); } // 3. 开始事务,批量写要素 outLayer.StartTransaction(); try { Feature srcFeat; while ((srcFeat = srcLayer.GetNextFeature()) != null) { Feature outFeat = new Feature(outLayer.GetLayerDefn()); // 逐字段复制 for (int i = 0; i < defn.GetFieldCount(); i++) { FieldDefn fldDefn = defn.GetFieldDefn(i); copyFieldValue(srcFeat, outFeat, fldDefn); } // 复制几何 Geometry geom = srcFeat.GetGeometryRef(); if (geom != null) { outFeat.SetGeometry(geom); } outLayer.CreateFeature(outFeat); outFeat.delete(); srcFeat.delete(); } outLayer.CommitTransaction(); } catch (Exception e) { outLayer.RollbackTransaction(); throw e; } }字段复制这里千万别偷懒只复制字段名和类型。我遇到过一次源gdb里字符串字段宽度是254,导出到GDB后变成默认宽度,长文本被截断。字段宽度和精度也必须一并带过来。
copyFieldValue方法按类型分别取值回填:
private static void copyFieldValue(Feature srcFeat, Feature outFeat, FieldDefn fldDefn) { String fldName = fldDefn.GetName(); int fType = fldDefn.GetFieldType(); if (fType == ogrConstants.OFTString) { outFeat.SetField(fldName, srcFeat.GetFieldAsString(fldName)); } else if (fType == ogrConstants.OFTInteger) { if (srcFeat.IsFieldNull(fldName)) { outFeat.SetFieldNull(fldName); } else { outFeat.SetField(fldName, srcFeat.GetFieldAsInteger(fldName)); } } else if (fType == ogrConstants.OFTInteger64) { if (srcFeat.IsFieldNull(fldName)) { outFeat.SetFieldNull(fldName); } else { outFeat.SetField(fldName, srcFeat.GetFieldAsInteger64(fldName)); } } else if (fType == ogrConstants.OFTReal) { if (srcFeat.IsFieldNull(fldName)) { outFeat.SetFieldNull(fldName); } else { outFeat.SetField(fldName, srcFeat.GetFieldAsDouble(fldName)); } } }注意源要素里可能字段值为NULL,直接用GetFieldAsString会返回空字符串或默认值,需要先判断IsFieldNull。这个细节在批量导出时特别重要,要不然导出前后数据就对不上了。
4.3 GDB导出的坐标系处理
创建图层时第三个参数传的是SpatialReference。如果你直接传srcLayer.GetSpatialRef(),导出结果会保留源数据本身的坐标系。这通常是最稳妥的做法。
但有一个新版GDAL经常出现的坑:轴顺序。GDAL 3.x对EPSG:4326这类地理坐标系按经纬度顺序处理,坐标对存成(纬度, 经度)的也有,尤其从某些gdb读出来的数据,在写入目标图层后可能出现经纬度对调的情况。解决办法是创建图层前处理好空间参考的轴顺序:
SpatialReference srs = srcLayer.GetSpatialRef(); if (srs != null) { srs.SetAxisMappingStrategy(ogrConstants.OAMS_TRADITIONAL_GIS_ORDER); }这个配置在导出到shapefile时同样重要,因为很多普通GIS软件默认仍然按传统的X=经度、Y=纬度来显示。你先在代码里把轴顺序固定,输出文件才不会出现“视图里图形翻转或坐标对调”的问题。
如果业务上需要重投影,比如把源数据的WGS84转成Web墨卡托,就要用CoordinateTransformation:
SpatialReference dstSrs = new SpatialReference(); dstSrs.ImportFromEPSG(3857); CoordinateTransformation ct = new CoordinateTransformation(srcSrs, dstSrs); Geometry newGeom = geom.Clone(); newGeom.Transform(ct);实际项目里我先明确需求,绝大多数情况是“保持原坐标系”,很少需要强制转投影。所以工具类里默认不转换,只把源坐标系完整带过去。
5. Shapefile批量导出:编码、坐标系和字段宽度都是坑
5.1 先解决DBF乱码:SHAPE_ENCODING和.cpg文件
shapefile的属性存在.dbf文件里,这个格式的编码机制非常老。它用一个LDID字节标识代码页,外加一个.cpg文件写明字符集。GDAL读取shapefile时,优先看.cpg文件里的声明;没有.cpg时,会试图根据LDID推断;推断不出来,就直接按系统默认编码处理。
在Linux服务器上,系统默认编码通常是UTF-8,如果源shp实际上是GBK编码且没有.cpg文件,读出来就是一堆乱码。反过来也一样:你导出的shp如果想让别人在Windows的GIS软件里正常打开,最好明确写清楚编码。
我的建议是在导出前统一设置:
gdal.SetConfigOption("SHAPE_ENCODING", "UTF-8");这样GDAL创建shapefile时会生成对应的.cpg文件。查看输出目录,应该看到类似图层名.cpg的文件,里面写着UTF-8。
如果源文件是GBK且没有cpg,读取时就要先告诉GDAL:
gdal.SetConfigOption("SHAPE_ENCODING", "GBK");然后再GetFieldAsString读取。GDAL会按照这个配置把字段值从GBK转成UTF-8字符串返回,这样Java侧拿到的就是正常中文。
这里有个小教训:很多人只设置SHAPE_ENCODING,却不检查.cpg文件是否生成,最后交付出去的shp在别人机器上打开还是乱码。务必验证输出目录里有cpg文件。
5.2 .prj文件是怎么生成的
shapefile的坐标系信息放在同名.prj文件里。GDAL在创建图层时,会根据传入的SpatialReference自动生成.prj文件。如果你创建图层时传了null,输出目录里就不会有.prj,别人拿到这个shp就不知道坐标系是什么。
所以导出shapefile时,代码里一定要这么写:
SpatialReference outSrs = srcLayer.GetSpatialRef(); if (outSrs != null) { outSrs.SetAxisMappingStrategy(ogrConstants.OAMS_TRADITIONAL_GIS_ORDER); } Layer outShpLayer = shpDs.CreateLayer(layerName, outSrs, geomType);创建完成后可以检查一下输出目录里的.prj文件是否存在,并且把内容打印出来看一眼:
if (outSrs != null) { System.out.println(outSrs.ExportToWkt()); }如果源图层没有坐标系,导出后自然没有.prj。这种数据要继续使用的话,通常在上游就应该补救。
5.3 字段类型和宽度:为什么导出后的shp字段会被截断
shapefile的字段存储格式很古老,限制也很多。最常见的三个坑:
第一,字符串字段宽度。源数据字段宽度可能很大,如果建目标字段时不显式复制宽度,GDAL会使用默认宽度。我遇到过宽度被截断到80,实际数据有200多个字符,导出后数据直接丢失。解决方案就是前面GDB部分写的,新建FieldDefn后把GetWidth()和GetPrecision()都带过来。
第二,字段名长度。shapefile的属性字段名最长10个字符,中文名也比较费劲。如果源字段名超过10字符,GDAL有些版本会截断,有些版本会直接创建失败。通用工具类可以对字段名做一次规范化,超过10个字符的改成短名,同时保持一个映射表方便后续追溯。
第三,整型范围。shapefile的.dbf字段用字符串或二进制存储数字,OFTInteger64如果超过一定位数,写入后可能需要扩展字段宽度。如果目标驱动报错,可以考虑把超长整型字段转成OFTReal或OFTString。具体来说,我在实际项目里对OFTInteger64加了一层判断,超过15位有效数字的,直接输出为字符串,避免精度丢失。
日期字段也要注意。shapefile的日期字段在.dbf里存储格式固定,如果源字段是带时分秒的时间戳,直接导成shapefile日期字段会丢时间部分。我一般建议保留为字符串或按业务需求决定。
5.4 输出目录的组织方式:一个图层对应一个数据源
shapefile驱动的数据源有两种组织方式。一种是把整个目录作为数据源,目录下可以放多个shapefile图层;另一种是每个shp文件独立创建数据源。我推荐后者,因为更简单、更稳:
Driver shpDriver = ogr.GetDriverByName("ESRI Shapefile"); String shpFilePath = outRoot + "/" + sanitizeLayerName(layerName) + ".shp"; DataSource shpDs = shpDriver.CreateDataSource(shpFilePath); if (shpDs == null) { throw new RuntimeException("创建shapefile失败: " + shpFilePath); }注意这里CreateDataSource的路径是带.shp后缀的完整文件路径,创建后GDAL会自动生成.shp、.shx、.dbf、.prj等配套文件。图层名要和文件名对应,建议先用srcLayer.GetName(),但要做一次清洗,去掉空格、特殊字符,避免生成非法文件名。
每个图层独立数据源的另一个好处是导出失败时,不会影响其他图层。我之前用目录作为数据源时,一个图层写失败,整个输出目录状态就变得不可控,排查起来也麻烦。
6. 几何对象转换、空几何和性能调优
6.1 几何类型不匹配是“导出成功但打不开”的头号原因
导出过程里最隐蔽的问题,是几何类型设置不对。源图层GetGeomType()返回的是wkbUnknown时,如果你直接用这个值创建目标图层,某些驱动会认为目标图层是“通用几何图层”,没什么问题;但也有些驱动会拒绝写入,或写入后目标文件打不开。
遇到wkbUnknown,我的做法是扫描前几百个要素,统计实际几何类型,再决定用哪种类型创建图层:
int realGeomType = ogrConstants.wkbUnknown; int count = 0; Feature f; while ((f = srcLayer.GetNextFeature()) != null && count < 200) { Geometry geom = f.GetGeometryRef(); if (geom != null) { realGeomType = geom.GetGeometryType(); count++; } f.delete(); }如果源数据里既有多边形,又有混合的面组成,建议统一按多部件类型创建,例如wkbMultiPolygon。这是因为多部件类型能容纳单部件和多部件两种几何,反过来则不一定。
空几何要素也要处理。有些图层的要素没有几何对象,GetGeometryRef()返回null。如果直接SetGeometry传入null,部分驱动会报错。正确写法是判断非null后再设置。
Z值和M值也要留意。如果源几何带Z坐标,目标图层类型要选对应的,比如wkbMultiPolygon25D,否则高程信息会被丢掉。做规划、建筑类数据时,高程丢失属于严重问题,宁可在导出前检查出来。
6.2 批量写入性能:事务、Chunk提交和对象释放
批量写入的性能差距可以非常大。最粗糙的写法是一个要素写一次,不做任何事务处理;稍微好一点的是分块提交。以一万要素为一块,在GDB里性能有明显提升:
int chunkSize = 10000; int idx = 0; outLayer.StartTransaction(); Feature srcFeat; while ((srcFeat = srcLayer.GetNextFeature()) != null) { // ... 创建并写入要素 ... idx++; if (idx % chunkSize == 0) { outLayer.CommitTransaction(); outLayer.StartTransaction(); } } outLayer.CommitTransaction();这样做的原因很简单:GDAL在事务内会缓存一批写入,提交时才真正落盘。如果整个图层有几十万要素,全塞进一个事务里,一旦中途出错,回滚成本也很高。分块提交既能提速,又控制了失败影响范围。
shapefile驱动本身对事务支持比较有限,但批量写入时同样要注意对象循环中的耗时。我在实测中发现,字符串字段的编码转换是主要性能瓶颈,尤其是中文长文本。如果导出性能不达标,优先看看是不是用了大量宽字符串字段。
6.3 JNI资源管理:Feature、DataSource什么时候delete
Java GDAL绑定里有个特别容易忽视的点:Feature、DataSource、Geometry这些JNI对象,靠Java垃圾回收是无法及时释放原生资源的。如果不主动调用delete(),打开几百个图层后,进程内存会越涨越高,最后卡死或者OOM。
我养成的习惯是:
- 循环里每创建一个
Feature,用完就delete(); - 每个图层导出完,把它相关的临时对象清理掉;
- 数据源用完在finally里
delete(); - 不要在一个方法里同时引用多个数据源而忘记关闭。
还要注意,DataSource.delete()之后,它所管理的Layer对象就失效了。所以关闭顺序应该是:先删完所有临时Feature,再删图层相关对象,最后删DataSource。我早期踩过坑,先关了DataSource再访问Layer,程序直接崩溃。
给一段统一的释放模板:
try { // 打开、遍历、导出 } finally { if (gdbDs != null) { gdbDs.delete(); gdbDs = null; } if (srcDs != null) { srcDs.delete(); srcDs = null; } }不要舍不得调delete,JNI对象的数量是实打实占用原生内存的。批量导出一晚上跑几百万要素,清理不及时,第二天早上来看大概率已经死了。
最后分享一个实践中的体会。我在这套工具类刚跑起来时,加了每个图层的耗时日志和源目标要素数量对比,导出完自动校验一次数量是否一致。这些日志在定位“某一张表少了要素”时帮了大忙。如果你也在做类似批量导出,强烈建议从第一天就带上日志和校验,先用一个小gdb验证全流程,确认环境、编码、坐标系都没问题,再上批量。服务器上的GDAL_DATA和原生库路径务必写绝对路径,这些配置细节越早固定,后面跑批越省心。