做GIS数据入库的都知道,shapefile这东西虽然老,却是矢量数据交换的主流格式之一。不少项目里,甲方给的数据一拖就是几百个shp,要求入库到Oracle里供地图服务查询。用Java把shapefile转成SQL再灌进Oracle,是一条听起来简单、实际踩坑不少的路。我最近在做一个数据整合任务时把这条流程完整跑了一遍,从读shp、拼SDO_GEOMETRY,到生成SQL文本、批量导库,最终整理了一套可以复用的方法。这篇就把它完整拆开讲清楚,适合已经会用Java、但对Oracle空间数据和shapefile内部结构还不够熟的开发者。
1. 方案定调:为什么绕不开SQL文本这一环
1.1 实际业务场景里真正要解决的是"两个世界"的对接
Shapefile本身是一个开放的文件格式,几何信息与属性信息分别存储在配套文件里。Oracle数据库存储空间数据用的是SDO_GEOMETRY对象,这是Oracle Spatial定义的一套类型体系。文件系统和数据库系统之间的数据搬运,可以走很多办法:直接用JDBC连接Oracle逐条插入、通过中间工具导入、又或者像我这样先把数据转成SQL文本再交给数据库执行。
假如要入库的是几百个shp,每个几万到几十万条要素,直接让Java代码连上数据库逐条组装SDO_GEOMETRY并执行INSERT,最大的问题有两个。第一,数据库会话长时间占用,事务一大很容易在回滚段和锁上出问题,中途失败排查麻烦。第二,数据在Java进程和数据库之间来回流动,性能受网络往返影响不可控。而先输出SQL脚本文件,再通过客户端工具批量执行,整个过程被拆成两个独立阶段:转换阶段与导入阶段。互不干扰,出问题分开排查,执行节奏完全由人控制。
1.2 三套候选方案的取舍
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| JDBC直接插入 | 流程最短、集成度高 | 长时间占连接、大批量性能波动、回滚困难 | 数据量小、易恢复 |
| 中间文件+SQL | 两阶段解耦、性能稳定、可离线检查 | 需要维护磁盘文件、脚本执行需外部工具 | 大批量数据入库 |
| 第三方ETL工具 | 零代码、图形化 | 对格式兼容和字段映射控制粒度不够 | 临时性、非核心流程 |
我选了第二套,主要原因有三。一是输出SQL文本后,可以在执行前用编辑器抽查几行,确认SDO_GEOMETRY拼得对不对,这比直接入库后发现问题再排查省事得多。二是生成SQL脚本的过程天然支持分布式处理,可以把不同shp分给多台机器生成,再汇总到一个目录统一执行。三是如果数据库账号权限受限,生成SQL文件后甚至可以交给专人执行,转换环节和导入环节不用绑定在同一个环境。
注意:"转SQL再导入"的代价是SQL文件往往很大。几十万条要素的SQL文件轻松上百MB,导库时别用图形工具直接拖,后面会讲怎么批量执行更稳。
2. 读懂shapefile和SDO_GEOMETRY,方案才有根基
2.1 Shapefile三件套:真正要读的其实只有shp和dbf
Shapefile由三个核心文件组成:
- .shp:几何数据,记录每个要素的点、线、面坐标。
- .dbf:属性数据,使用dBase格式,一行对应一条要素。
- .shx:几何索引,建立每条记录在shp文件中的偏移量。
shp文件本身有固定结构。文件头有100个字节,包含文件代码、全长、坐标范围等信息。接下来每条记录都有8字节的记录头和长度不等的几何内容。几何内容按类型分为Point、PolyLine、Polygon、MultiPoint几类,坐标以double精度存储。
作为开发人员,其实没有必要逐字节解析shp信息,Java生态里已经有成熟的解析库把底层格式细节封装好了,直接读取Feature即可。真正需要留意的是.dbf里的字段类型与值,因为这才是属性数据能否正确进入Oracle的关键。
2.2 Oracle SDO_GEOMETRY到底是个什么东西
Oracle Spatial用SDO_GEOMETRY类型描述地理要素。一个完整的SDO_GEOMETRY由5个部分组成,理解它的构造规则是生成SQL的前提。
- SDO_GTYPE:几何类型代码,四位数,千位表示线条维度,十位表示类型(1:点,2:线,3:面),个位表示坐标维度。
- SDO_SRID:空间参考标识符,比如4326对应WGS84经纬度坐标系,为空表示未知坐标系。
- SDO_POINT_TYPE:几何是点对象时使用,一般情况填NULL。
- SDO_ELEM_INFO_ARRAY:描述几何元素的类型和坐标起始位置。
- SDO_ORDINATE_ARRAY:存储坐标值的有序数组。
面对象最容易让人迷惑。以外环为例,SDO_ELEM_INFO_ARRAY中的(1, 1003, 1)表示坐标从第1位开始、是简单外环、以顺序坐标对构成。内环则用(offset, 2003, 1),2003代表简单内环。坐标数组中,面的外环和内环都要保证首尾相接才能闭合,否则数据库会报元素无效的错误。
2.3 几何类型映射表
把shapefile的几何类型对应到SDO_GTYPE,是生成SQL时最直接的参考:
| Shapefile几何类型 | SDO_GTYPE | ELEM_INFO说明 |
|---|---|---|
| Point | 2001 | (1, 1, 1) |
| PolyLine | 2002 | (1, 2, 1) |
| Polygon | 2003 | 外环(1, 1003, 1),内环(offset, 2003, 1) |
| MultiPoint | 2005 | (1, 1, 1) |
补充一点,多面MultiPolygon在shapefile中也是Polygon类型,但一个几何里可能包含多个面。读出来之后要判断Geometry的实例类型,如果是MultiPolygon,可以遍历每个Polygon生成独立的SDO_GEOMETRY,也可以映射成Oracle的MULTIPOLYGON。我一般偏向把MultiPolygon拆成多个Polygon记录,这取决于业务上是查"整个地块"还是查"地块的每块碎片"。
3. Java读取Shapefile并生成SQL的完整实现
3.1 环境准备与依赖配置
我用GeoTools来读取shapefile,用JTS处理几何对象。GeoTools对shapefile读写的封装比较完善,社区活跃,JDK8以上的项目直接用Maven拉依赖即可。版本选取要跟JDK匹配,不要盲目追新。下面是最小依赖集合:
<dependency> <groupId>org.geotools</groupId> <artifactId>gt-shapefile</artifactId> <version>28.2</version> </dependency> <dependency> <groupId>org.geotools</groupId> <artifactId>gt-main</artifactId> <version>28.2</version> </dependency> <dependency> <groupId>org.geotools</groupId> <artifactId>gt-referencing</artifactId> <version>28.2</version> </dependency>gt-referencing主要用于坐标系读写和转换,后面讲SRID处理时会用到。如果只是简单读取并生成SQL,不涉及坐标转换,可以去掉。第一次构建拉全依赖可能要等一会,别着急。
提示:GeoTools的依赖有时候会比较冗余,实际项目里可以按需精简,但gt-shapefile和gt-main通常都要保留。
3.2 核心实现:遍历要素、读取几何与属性
读取shp文件的核心思路是通过DataStore拿到FeatureSource,然后流式遍历所有Feature。所谓流式,是指读一条处理一条,不会把所有数据加载到内存,这对大文件非常重要。
File shpFile = new File("/data/land_parcel.shp"); Map<String, Object> params = new HashMap<>(); params.put("url", shpFile.toURI().toURL()); try (DataStore dataStore = DataStoreFinder.getDataStore(params)) { String typeName = dataStore.getTypeNames()[0]; FeatureSource<SimpleFeatureType, SimpleFeature> featureSource = dataStore.getFeatureSource(typeName); SimpleFeatureType schema = featureSource.getSchema(); AttributeDescriptor geometryDescriptor = schema.getGeometryDescriptor(); try (FeatureIterator<SimpleFeature> iterator = featureSource.getFeatures().features()) { while (iterator.hasNext()) { SimpleFeature feature = iterator.next(); Geometry geometry = (Geometry) feature.getDefaultGeometry(); if (geometry == null) { continue; } // 逐条生成SQL } } }这里有几个值得注意的细节。第一,getTypeNames()[0]是默认的shp数据层名称,如果shp文件名包含特殊字符,可能要做处理。第二,getGeometryDescriptor取到的是几何字段的描述,真正读取每个要素的几何用的是getDefaultGeometry(),这是GeoTools约定的默认几何入口。第三,遍历AttributeDescriptor时要把几何字段跳过,因为几何字段要走SDO_GEOMETRY包装,不能当成普通属性拼进INSERT语句。
属性读取的代码:
for (AttributeDescriptor attr : schema.getAttributeDescriptors()) { String name = attr.getLocalName(); if (name.equals(geometryDescriptor.getLocalName())) { continue; } Object value = feature.getAttribute(name); // 根据 attr.getType().getBinding() 判断类型 }3.3 生成SDO_GEOMETRY的SQL片段
这一步是整个方案的核心,也是最容易写错的地方。SDO_GEOMETRY的SQL字符串构造需要严格遵循Oracle Spatial格式。我写了一个独立方法,传入JTS的Geometry和SRID,返回可直接拼进INSERT语句的SDO_GEOMETRY构造串。
public static String geometryToSdoGeometry(Geometry geometry, int srid) { String sridStr = (srid > 0) ? String.valueOf(srid) : "NULL"; if (geometry instanceof Point point) { return String.format( "MDSYS.SDO_GEOMETRY(2001, %s, MDSYS.SDO_POINT_TYPE(%s, %s, NULL), NULL, NULL)", sridStr, point.getX(), point.getY()); } else if (geometry instanceof LineString line) { return String.format( "MDSYS.SDO_GEOMETRY(2002, %s, NULL, " + "MDSYS.SDO_ELEM_INFO_ARRAY(1, 2, 1), " + "MDSYS.SDO_ORDINATE_ARRAY(%s))", sridStr, coordinateArray(line.getCoordinates())); } else if (geometry instanceof Polygon polygon) { return polygonToSdoGeometry(polygon, sridStr); } else { throw new IllegalArgumentException("暂不支持的几何类型: " + geometry.getGeometryType()); } }coordinateArray方法负责把Coordinate数组拼成"x1 y1, x2 y2, ..."的格式。注意Oracle的坐标数组是平整排列的,不需要用括号把每个坐标点单独包起来:
private static String coordinateArray(Coordinate[] coords) { StringBuilder sb = new StringBuilder(); for (Coordinate coord : coords) { sb.append(coord.x).append(" ").append(coord.y).append(", "); } sb.setLength(sb.length() - 2); return sb.toString(); }Polygon是重头戏,要同时处理外环和内环:
private static String polygonToSdoGeometry(Polygon polygon, String sridStr) { StringBuilder elemInfo = new StringBuilder("MDSYS.SDO_ELEM_INFO_ARRAY(1, 1003, 1"); StringBuilder ordinates = new StringBuilder("MDSYS.SDO_ORDINATE_ARRAY("); Coordinate[] exterior = polygon.getExteriorRing().getCoordinates(); appendCoordinates(ordinates, exterior); int offset = exterior.length; for (int i = 0; i < polygon.getNumInteriorRing(); i++) { Coordinate[] interior = polygon.getInteriorRingN(i).getCoordinates(); elemInfo.append(", ").append(offset + 1).append(", 2003, 1"); appendCoordinates(ordinates, interior); offset += interior.length; } elemInfo.append(")"); ordinates.setLength(ordinates.length() - 2); ordinates.append(")"); return String.format("MDSYS.SDO_GEOMETRY(2003, %s, NULL, %s, %s)", sridStr, elemInfo, ordinates); } private static void appendCoordinates(StringBuilder sb, Coordinate[] coords) { for (Coordinate coord : coords) { sb.append(coord.x).append(" ").append(coord.y).append(", "); } }这里需要特别强调offset的计算。SDO_ELEM_INFO_ARRAY中的值不是坐标索引,而是整个SDO_ORDINATE_ARRAY里第几个坐标。外环从1开始,占用了exterior.length个坐标,那么内环自然就从offset+1开始。我最初在这个offset上栽过跟头,错写成按元素个数算,结果导入时Oracle直接报"坐标数量无效"。这个细节建议每个做的人都亲手验算一遍。
3.4 属性值的SQL格式化与转义
属性值看起来只是简单的拼字符串,实际坑并不少。数字类型直接toString即可;字符串类型必须处理单引号,因为DBF里的地名、说明文本很可能包含单引号;日期类型在Oracle里要转成TO_DATE函数;NULL值单独处理。
public static String formatValue(Object value) { if (value == null) { return "NULL"; } if (value instanceof Number number) { return number.toString(); } if (value instanceof Date date) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return "TO_DATE('" + sdf.format(date) + "', 'YYYY-MM-DD HH24:MI:SS')"; } String text = value.toString().replace("'", "''"); return "'" + text + "'"; }String的replace这一步相当关键。漏掉的话,只要属性值里带一个单引号,整条INSERT语句就可能被截断,轻则报错,重则造成后续SQL语义错乱。真正在项目中跑数据之前,最好写一个小测试,专门构造包含单引号、双引号、中文逗号等特殊字符的属性值,验证生成的SQL语法正确。
3.5 输出SQL文件与写入策略
生成SQL的写入策略有两个要点。一个是编码,SQL文件一定要用UTF-8编码写出,因为shp属性字段经常有中文,而Oracle客户端连接默认字符集不一定和UTF-8一致。如果SQL文件用了平台默认编码(比如Windows上的GBK),入库时很容易乱码。Java里指定编码的正确姿势:
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter( new FileOutputStream(sqlFile), StandardCharsets.UTF_8))) { writer.println(sql); }另一个是文件切分。几十万条INSERT都写进一个SQL文件,执行时如果某一行报错,后续检查会变得非常麻烦。我的做法是每个shp文件生成一个独立SQL文件,并且给每条INSERT加一行注释记录数据在原文件中的序号,这样即使报错,也能快速定位到具体是哪条数据出的问题。
-- 数据行号: 1001 INSERT INTO T_LAND_PARCEL (GEOM, PARCEL_ID, NAME, AREA) VALUES (MDSYS.SDO_GEOMETRY(...), 'P1001', '某地块', 1234.56);批量执行时可以根据行号反查原始数据,排查效率会高不少。
4. 导入Oracle数据库的实操流程
4.1 目标表结构与空间元数据预先准备
生成SQL只是第一步,要让Oracle真正接受这些SDO_GEOMETRY数据,目标表结构得先准备好。最简单的方式:
CREATE TABLE T_LAND_PARCEL ( ID NUMBER(10) PRIMARY KEY, PARCEL_ID VARCHAR2(30), NAME VARCHAR2(200), AREA NUMBER(15, 2), GEOM MDSYS.SDO_GEOMETRY );建完表别急着插数据,还要注册空间元数据。Oracle Spatial有一个元数据视图叫USER_SDO_GEOM_METADATA,不往这个视图里插入记录,就无法创建空间索引;没有空间索引就做不了空间查询。元数据里要写清楚维度范围和容差:
INSERT INTO USER_SDO_GEOM_METADATA (TABLE_NAME, COLUMN_NAME, SRID, DIMINFO) VALUES ( 'T_LAND_PARCEL', 'GEOM', 4326, MDSYS.SDO_DIM_ARRAY( MDSYS.SDO_DIM_ELEMENT('X', -180, 180, 0.000001), MDSYS.SDO_DIM_ELEMENT('Y', -90, 90, 0.000001) ) ); COMMIT; CREATE INDEX IDX_T_LAND_PARCEL_GEOM ON T_LAND_PARCEL(GEOM) INDEXTYPE IS MDSYS.SPATIAL_INDEX;容差的最后那个0.000001表示坐标容差,单位与坐标系一致。WGS84经纬度数据用这个量级合适,如果是投影坐标(单位是米),可以考虑0.001或0.01。空间索引的创建时间取决于数据量,几十万条数据一般在几秒到几十秒之间,数据超过百万的话可能需要几分钟,执行时要有点耐心。
4.2 大批量SQL文件的执行方式
拼好的SQL文件往往几百MB,直接用图形化工具拖执行会卡到怀疑人生。推荐用SQL*Plus或者SQLcl这类命令行工具分批执行。
SQL*Plus的用法很简单:
sqlplus user/password@connection_identifier @batch_001.sql如果希望同一个事务内出错全部回滚,可以在脚本外层套一个BEGIN/EXCEPTION/ROLLBACK结构。但几十万条INSERT放进一个事务也有风险,一旦中途崩了,回滚也要耗很长时间。我的实践经验是每2万条左右一个事务分批次提交,这样既能控制失败影响面,又能降低事务日志压力。
如果不想生成SQL文件,而是直接通过Java程序插入,可以这样:
conn.setAutoCommit(false); PreparedStatement ps = conn.prepareStatement("INSERT INTO ... VALUES (?, ?)"); for (...) { ps.setObject(1, ...); ps.setObject(2, ...); ps.addBatch(); } ps.executeBatch(); conn.commit();不过前面已经说过,大批量场景我更倾向SQL文件方案,因为SQL文件可以脱离Java进程单独执行、单独排查。至于到底选哪种,没有绝对的对错,核心是每个事务的规模要可控。
4.3 坐标系的统一处理
shapefile文件里通常会带一个.prj文件描述坐标系统,有的是WGS84经纬度(EPSG:4326),有的是各地常用的投影坐标。Oracle空间数据的SRID要和几何坐标的值域匹配,否则空间索引的DIMINFO范围写不对,查询结果也会有偏差。
如果不匹配,有两种处理手段。一种是在Java里直接做坐标转换,用GeoTools的Referencing模块:
CoordinateReferenceSystem sourceCrs = CRS.decode("EPSG:4547"); CoordinateReferenceSystem targetCrs = CRS.decode("EPSG:4326"); MathTransform transform = CRS.findMathTransform(sourceCrs, targetCrs, true); Geometry transformed = JTS.transform(geometry, transform);另一种是保持原始坐标不变,把Oracle元数据里的SRID和目标坐标系都定义成原始坐标系。这种方式适合下游系统本来就要用同一套坐标系的情况。
我最终选择了统一转成EPSG:4326,因为地图服务按经纬度数据来处理最省心。要注意的是,坐标转换涉及投影变形,面积、长度都会发生变化,涉及面积计算的业务需要评估转换后的精度影响。
5. 高频问题与排查手册
5.1 DBF中文乱码
DBF文件的字符集不固定,完全取决于生成时的设置,常见的有GBK、UTF-8、ANSI等。GeoTools读取DBF时默认使用系统字符集,如果读取后中文全部变成乱码,说明编码判断错了。解决方式是在读取前指定字符集:
params.put("charset", Charset.forName("GBK"));具体用GBK还是UTF-8,没法从文件名判断,只能打开里面的一条中文记录试一次。我的习惯是先用纯文本编辑器打开dbf的二进制内容,看中文字节的编码特征,或者各试一遍,哪个不乱码就用哪个。
5.2 SDO_GEOMETRY坐标数组报错
报错信息类似"ORA-13349: geometry coordinate array is invalid"时,大概率是坐标数组的问题。常见原因有三个:
- 外环的坐标没有闭合,也就是最后一个坐标和第一个坐标不相同。
- SDO_ELEM_INFO_ARRAY里的offset算错。
- 源shp里有空几何或退化几何,比如两个点重合的线、面积为0的面。
排查方式是先把失败那条数据对应的SDO_GEOMETRY字符串单独复制到SQL客户端里执行,看Oracle具体报什么错。再把该条Geometry用JTS的isValid()方法检查一次,如果JTS也认为无效,问题基本就出在源数据上,而不是生成SQL的逻辑上。
5.3 大批量导入时主键冲突
shapefile属性里没有自增主键,主键冲突通常是自己造出来的。我遇到过一种情况,多个shp分别生成SQL后,主键ID都是从1开始的,合并入库时全部冲突。解决办法是生成SQL时给不同shp的主键加一个偏移量,或者用数据库序列替代:
INSERT INTO T_LAND_PARCEL (ID, PARCEL_ID, NAME, GEOM) VALUES (SEQ_LAND_PARCEL.NEXTVAL, ...)更稳妥的做法是生成SQL时直接不带ID,入库后通过Sequence填充,这样不管有多少个shp合并都不会冲突。
5.4 内存溢出
GeoTools默认的FeatureIterator是流式的,按理说不容易OOM。但如果你在解析时习惯性用list.add(feature)把所有要素先收集起来,再统一处理,几十万条要素分分钟堆爆内存。我的建议是始终坚持"读一条,解析一条,写一条"的模式。如果确实需要对全量数据做排序或去重,就该考虑引入中间文件或者临时表,而不是一股脑堆进内存。
5.5 常见问题速查表
| 现象 | 大概率原因 | 处理建议 |
|---|---|---|
| 中文属性乱码 | DBF字符集判断错误 | 在DataStore参数里指定charset |
| ORA-13349坐标数组无效 | 坐标未闭合或offset错误 | 用JTS.isValid()和手工验算offset |
| 插入后空间查询查不到 | 空间元数据未注册 | 检查USER_SDO_GEOM_METADATA |
| 空间索引创建失败 | 存在NULL几何或退化几何 | 先过滤无效几何再建索引 |
| 大量INSERT执行很慢 | 每条自动提交或事务过大 | 每2万条一批提交,或改用SQL*Loader |
6. 一点实操心得
这套"shapefile转SQL导Oracle"的方法,最大的价值在于把数据转换和数据库入库两个环节完全解耦,逻辑清楚、排查方便。项目里如果只是几千条数据的轻量导入,用JDBC直连反而更快;一旦数据量上了几十万甚至百万,老老实实生成SQL文件再批量执行,过程会稳定不少。
实际操作中我还有几个小习惯。每次生成完SQL,先随机抽5到10行人工检查一下SDO_GEOMETRY结构是否正常、属性值是否被正确转义。再把SQL文件拆成几百KB的小块,先导一块验证,没问题再跑全量。这些习惯让我少踩了很多坑。
后续如果有余力,还可以在这套方案上继续扩展。比如把生成SQL的Java工具封装成命令行工具,支持配置文件指定表名、SRID、字符集;或者增加校验模式,在生成SQL时顺便用JTS的isValid()过滤无效几何;再进一步,可以调整输出格式同时支持PostgreSQL的WKB格式,同一套读取逻辑就能服务多个数据库平台。对长期和GIS数据打交道的项目来说,这些扩展都不算难,但收益非常直接。
我个人的体会是,GIS这类开发任务,技术难点往往不在于某个框架有多难,而在于两个系统之间的数据模型差异。把shapefile三件套的结构、Oracle SDO_GEOMETRY的构造规则、JTS的几何类型体系这三样东西吃透,后面的转换逻辑写起来会顺很多。真遇到奇怪的数据,开个测试类把单条记录输出成文本慢慢看,比到处查资料更管用。