这两年做离线矢量数据处理,我几乎每隔一阵就要重新写一遍 shp 文件的读取逻辑。Java 读取 shp 文件、把里面的点和线的坐标点取出来,听起来像是“引个依赖、调个方法”就完事的活,但真上手你会发现麻烦全在细节里:脚本跑通了,导出来的点却全挤在零点附近;线的坐标串被切成了好几段,首尾还接不上;中文属性读出来是问号;几十万条记录一口气读进内存,堆直接炸掉。这些问题当年的我一条条趟过来,后来干脆抛开第三方封装,照着规范自己写了套纯字节解析器,反而最稳。
这篇东西写给谁看?一类是刚开始接触地理数据、被 shp 这个格式整懵的 Java 开发者;另一类是在数据清洗、坐标转换、离线地图预处理、空间索引构建这些场景里,需要把点和线的坐标原样抠出来、又不想被庞大的 GIS 依赖绑住的人。下面我会从 shp 的字节布局讲起,一路到完整可跑的解析代码,再聊属性表编码、投影单位、大文件内存这几个必踩的坑。
1. 先搞清楚 shp 到底是个什么结构,别拿它当普通二进制瞎读
1.1 一个 shp 其实是四件套,缺一件都可能翻车
很多人第一次拿到数据,目录里躺着同名的一堆文件,只会双击那个后缀是 shp 的。实际上一个完整的 shp 数据源是一组协同工作的文件:.shp存几何实体,.shx存每条记录在 shp 里的字节偏移索引,.dbf存属性表,.prj存坐标参考系的 WKT 描述,有时候还有个.cpg说明 dbf 用什么字符编码。它们共用一个文件名主干,扩展名不同。
这几个文件的分工得掰清楚,否则你排查问题时会找错方向。.shp是唯一必须存在的,几何全在里面,它本身是个“顺序记录流”:100 字节文件头 + 若干条记录,每条记录 8 字节记录头 + 记录内容。.shx本质是100 + 8n字节的定长数组,每 8 字节存一条记录的偏移和长度,作用相当于书的目录,没有它也能读,只是没法随机跳转。.dbf是典型的桌面数据库表格格式,几何和属性靠“记录序号”隐式对应——shp 里第 1 条记录,对应 dbf 里第 1 行。.prj是纯文本,一行 WKT,告诉你这堆数字到底是经纬度还是米。
提醒一句:
.shx缺失是高频事故。有些数据在传输过程中被单独挑了 shp 出来,或者压缩包解压不全。顺序读不受影响,但你要是按索引随机访问就会直接报错,所以代码里对.shx要当成可选依赖处理。
我把这四个文件的作用整理成一张表,后面写代码时你会反复回来看它:
| 文件 | 是否必需 | 内容性质 | 缺失后果 |
|---|---|---|---|
| .shp | 必需 | 二进制,几何实体记录流 | 无法读取 |
| .shx | 可选 | 二进制,定长偏移索引 | 只能顺序遍历,不能随机定位 |
| .dbf | 可选 | 二进制,定长表头 + 定长行 | 拿不到属性字段 |
| .prj | 可选 | 纯文本 WKT | 不知道坐标系和单位 |
| .cpg | 可选 | 纯文本编码名 | 中文属性大概率乱码 |
1.2 100 字节文件头里,其实只有 6 个字段真正有用
shp 的文件头固定 100 字节,官方定义了一堆字段,但从工程角度看,你真正需要校验和使用的就那么几个:文件码、文件长度、版本号、几何类型、以及包围盒的四角坐标。剩下的保留字段全是 0,读不读无所谓。
这里有个新手几乎百分百会栽的坑——大小端混用。文件头的前 24 字节(文件码 + 5 个保留 int)是大端存储,从第 28 字节开始(版本号、几何类型、包围盒)全部切成小端。而 Java 的DataInputStream.readInt()和readDouble()默认都是大端,你直接读出来的版本号会是个天文数字,包围盒会变成一堆 NaN 或者 1.0E-320 这种诡异值。
我见过最典型的症状:有人读出包围盒最大值是4.9E-324,还以为是数据本身坏了,其实是字节序反了。解决办法有两个,要么用ByteBuffer读一段切一次order(),要么老老实实写两个工具方法readIntLE/readDoubleLE。我后面两种都会给。
文件头字段的偏移和字节序,这张表建议存下来:
| 偏移(字节) | 长度 | 类型 | 字节序 | 含义 | 是否常用 |
|---|---|---|---|---|---|
| 0 | 4 | int | 大端 | 文件码,固定 9994 | 必校验 |
| 4 | 20 | int×5 | 大端 | 保留,全 0 | 不看 |
| 24 | 4 | int | 大端 | 文件总长,单位是 16 位字 | 必用 |
| 28 | 4 | int | 小端 | 版本号,固定 1000 | 建议校验 |
| 32 | 4 | int | 小端 | 几何类型 | 必用 |
| 36 | 32 | double×4 | 小端 | Xmin/Ymin/Xmax/Ymax | 常用 |
| 68 | 32 | double×4 | 小端 | Zmin/Zmax/Mmin/Mmax | 少用 |
注意文件长度那个字段的单位是“16 位字”,也就是 2 字节。所以真实字节数 = 该值 × 2。这个设计在 32 位时代能多撑一位表示范围,现在成了个纯陷阱,我第一次读的时候少乘了 2,结果只解析了半个文件,还以为是记录被截断了。
2. 造轮子还是上现成工具链,先看你的场景
2.1 两条路线的取舍,不是越重越好
Java 生态里读 shp 有两条路:一条是用成熟的开源 GIS 工具链,几行代码就能把整个数据源挂上来,几何、属性、投影一把梭;另一条是自己照着规范解析字节流。这俩没有绝对优劣,关键看你的项目形态。
现成工具链的优势是省事,几何对象模型完整,缓冲区分析、相交判断、坐标转换这些能力开箱即用,属性表查询也帮你做了。代价是依赖体积不小,一个读 shp 的需求可能拖进来几十个 jar,启动时还会加载一堆坐标系定义数据,冷启动慢;另外它对你代码的侵入性强,你的业务对象往往要适配它的几何类型,后面想换实现就得大改。
自己解析的优势是极轻,一个类几百行,零第三方依赖,JDK 8 就能跑,还能顺手做流式处理——边读边算,不把整份数据堆在内存里。代价是你得自己处理字节序、几何类型分派、Z/M 值、环方向这些细节,而且拿不到现成的拓扑运算能力。
我的判断标准很简单:如果只是“把坐标抠出来”,比如做数据校验、格式转换、导入自有存储、生成瓦片前的预处理,手写解析器更划算;如果后面要做空间关系判断、叠加分析、动态投影,那还是别硬扛,直接用成熟工具链,把精力放在业务上。下面讲的所有内容,即便你用工具链,也不白看——理解了字节布局,你排查“为什么这条线少了一截”这类问题时,速度会快一个量级。
| 维度 | 手写解析器 | 成熟 GIS 工具链 |
|---|---|---|
| 依赖体积 | 零依赖 | 较大,含多个传递依赖 |
| 冷启动 | 毫秒级 | 秒级,需加载索引数据 |
| 内存控制 | 可流式,精确可控 | 通常在数据源层封装 |
| 几何运算 | 需自行实现 | 开箱即用 |
| 排查难度 | 出问题定位到自己代码 | 需理解框架内部行为 |
| 适合场景 | 抽取、转换、校验 | 分析、叠加、渲染 |
2.2 我的分层设计:字节层、几何层、业务层严格切开
写这种解析器最容易犯的错,是把“从流里读字节”和“业务要什么数据”搅在一块,最后代码变成一坨,稍微换个需求就得动核心逻辑。我一般分三层。
字节层只负责一件事:从流里准确地把 int、double 读出来,并且保证字节序正确。这一层对外只暴露readIntLE、readDoubleLE这类极小的方法,或者一个已经设好order(LITTLE_ENDIAN)的ByteBuffer。它不理解点,也不理解线。
几何层负责把一条记录的原始字节翻译成结构化的坐标数据——点就是一对 x/y,线就是一串按 parts 切好的坐标数组。这一层不关心业务字段,也不要往里塞任何“顺便算个长度”的逻辑,长度属于消费方的事。
业务层才是你的回调、你的数据库写入、你的格式转换。它只接收干净的双精度坐标数组,不碰任何字节。这样分层之后,哪天你说要改成读 GeoJSON,几何层重写、业务层一行不动;哪天要加个 Z 值支持,也只影响几何层。
这种切法的另一个好处是可测试性。字节层的单元测试用几段硬编码的十六进制就能覆盖;几何层拿一个几十字节的模拟记录就能验证;业务层干脆 mock 掉。真出线上问题,你从哪个环节的日志断掉,一眼就知道问题在哪层。
3. 核心代码实现:从字节流到点、线坐标的完整链路
3.1 小端读取工具与文件头解析
先解决字节序。我不喜欢在每个读取点都手动reverseBytes,容易漏。做法是把 100 字节文件头整体读进数组,用一个ByteBuffer包起来:前 24 字节用大端读,然后把order切成小端继续读。这一招对文件头最合适,因为它只有 100 字节,一次性读完毫无压力。
import java.io.*; import java.nio.*; public final class ShpHeader { public final int fileCode; // 固定 9994 public final long fileLengthBytes; public final int version; // 固定 1000 public final int shapeType; // 几何类型 public final double minX, minY, maxX, maxY; private ShpHeader(int fileCode, long fileLengthBytes, int version, int shapeType, double minX, double minY, double maxX, double maxY) { this.fileCode = fileCode; this.fileLengthBytes = fileLengthBytes; this.version = version; this.shapeType = shapeType; this.minX = minX; this.minY = minY; this.maxX = maxX; this.maxY = maxY; } public static ShpHeader read(DataInputStream in) throws IOException { byte[] buf = new byte[100]; in.readFully(buf); ByteBuffer bb = ByteBuffer.wrap(buf); // 前段:大端 bb.order(ByteOrder.BIG_ENDIAN); int fileCode = bb.getInt(0); if (fileCode != 9994) { throw new IOException("文件码不是 9994,实际=" + fileCode + ",这不是 shp 文件"); } int fileLengthWords = bb.getInt(24); long fileLengthBytes = (long) fileLengthWords * 2L; // 单位是 16 位字 // 后段:小端 bb.order(ByteOrder.LITTLE_ENDIAN); int version = bb.getInt(28); int shapeType = bb.getInt(32); double minX = bb.getDouble(36); double minY = bb.getDouble(40); double maxX = bb.getDouble(44); double maxY = bb.getDouble(48); if (version != 1000) { // 规范里就是 1000,不是的话多半是字节序又搞错了 System.err.println("警告:版本号异常 " + version + ",请检查字节序"); } return new ShpHeader(fileCode, fileLengthBytes, version, shapeType, minX, minY, maxX, maxY); } }这段代码里我把fileLengthBytes算成long,不是多余——单个 shp 文件理论上限是 2GB(因为长度字段只有 32 位、单位还是字),但实际业务里切分后的文件经常贴着上限跑,中间算乘法时用 int 有溢出风险。虽然最终值不会超过 2GB,用 int 也行,但习惯上我宁愿多写一个long,省得以后改。
3.2 记录循环:别再一条条数,用消耗字节数做循环条件
读完文件头,接下来是遍历记录。每条记录的结构是固定的:8 字节记录头(记录号 4 字节 + 内容长度 4 字节,都是大端)+ 记录内容(长度 = 内容长度 × 2 字节)。记录内容里第一个 int 是这条记录自己的几何类型,小端。
这里最容易写错的是循环终止条件。很多人用while (in.available() > 0),但BufferedInputStream的available()返回的是缓冲区里可读的字节数,不是文件剩余长度,读到缓冲区边界就提前退出了。正确做法是用文件头里的总长度做基准,累加已消耗字节数。
public final class ShpRecordReader implements Closeable { private final DataInputStream in; private final long totalBytes; private long consumed = 100; // 文件头已经吃掉 100 字节 public ShpRecordReader(File shpFile, ShpHeader header) throws IOException { this.in = new DataInputStream( new BufferedInputStream(new FileInputStream(shpFile), 1 << 16)); this.totalBytes = header.fileLengthBytes; // 跳过头 100 字节 long skipped = in.skip(100); if (skipped != 100) throw new IOException("跳过文件头失败"); } public boolean hasNext() { return consumed < totalBytes - 8; // 至少要剩一个记录头 } public byte[] nextContent() throws IOException { int recordNumber = in.readInt(); // 大端,从 1 开始 int contentLenBytes = in.readInt() * 2; // 大端,单位是 16 位字 // 防御:损坏文件里这个值可能是负数或离谱的大数 if (contentLenBytes < 4 || contentLenBytes > 256 * 1024 * 1024) { throw new IOException("记录 " + recordNumber + " 长度异常: " + contentLenBytes); } byte[] content = new byte[contentLenBytes]; in.readFully(content); consumed += 8L + contentLenBytes; return content; } @Override public void close() throws IOException { in.close(); } }把nextContent()返回成byte[]而不是直接解析,是我刻意做的解耦——几何解析逻辑可以独立测试,不用真的建文件。而且后面的ByteBuffer.wrap(content).order(LITTLE_ENDIAN)读起来非常清爽,不用在每个getDouble后面挂个reverseBytes。
注意:记录号理论上从 1 递增,但现实中确实存在不按顺序、甚至有重复的脏数据。所以如果你的业务需要和 dbf 属性行对齐,不要用记录号做索引,要用“第几条”这个顺序位,边读边计数。
3.3 Point、PolyLine、Polygon 的字节布局,一个一个拆
记录内容的第一件事是把几何类型读出来(小端 int),然后按类型分派。常见类型的布局差异很大,先看对照表:
| 类型值 | 名称 | 内容布局(首 int 之后) |
|---|---|---|
| 0 | Null | 无,就 4 字节类型 |
| 1 | Point | X(double) + Y(double),共 16 字节 |
| 3 | PolyLine | Box(32) + NumParts(4) + NumPoints(4) + Parts + Points |
| 5 | Polygon | 同 PolyLine |
| 8 | MultiPoint | Box(32) + NumPoints(4) + Points |
| 11 | PointZ | X + Y + Z + M,共 32 字节 |
| 13 | PolyLineZ | Box + NumParts + NumPoints + Parts + Points + Z 段 + M 段 |
| 15 | PolygonZ | 同 PolyLineZ |
| 23 | PolyLineM | Box + NumParts + NumPoints + Parts + Points + M 段 |
点最好办,直接两个 double。线就麻烦在Parts这个东西上。一个 PolyLine 记录里可能存着好几条互不相连的线段,比如一条省界被河流打断了,或者一条道路由多个不连续的路段组成。NumParts告诉你分成几段,Parts数组告诉你每一段的起始点在 Points 数组里的下标,最后一段的结束位置就是NumPoints。
public static GeometryData readGeometry(ByteBuffer cb) { int shapeType = cb.getInt(); // 小端,调用方已设好 order switch (shapeType) { case 0: return null; // 空几何,直接跳过 case 1: { double x = cb.getDouble(); double y = cb.getDouble(); return new PointData(x, y); } case 3: case 5: { return readParts(cb, shapeType == 5); } case 8: { cb.position(cb.position() + 32); // 跳过 Box int n = cb.getInt(); double[] xs = new double[n], ys = new double[n]; for (int i = 0; i < n; i++) { xs[i] = cb.getDouble(); ys[i] = cb.getDouble(); } return new MultiPointData(xs, ys); } case 11: { double x = cb.getDouble(); double y = cb.getDouble(); cb.getDouble(); // Z // M 可能是 no-data,先读出来备用 double m = cb.getDouble(); return new PointData(x, y); } default: throw new UnsupportedOperationException("暂不支持的几何类型: " + shapeType); } } private static PolyData readParts(ByteBuffer cb, boolean closed) { cb.position(cb.position() + 32); // 跳过 Box(Xmin/Ymin/Xmax/Ymax) int numParts = cb.getInt(); int numPoints = cb.getInt(); if (numParts <= 0 || numPoints <= 0 || numParts > numPoints) { throw new IllegalStateException("parts/points 数量异常: " + numParts + "/" + numPoints); } int[] startIdx = new int[numParts]; for (int i = 0; i < numParts; i++) { startIdx[i] = cb.getInt(); } double[] xs = new double[numPoints]; double[] ys = new double[numPoints]; for (int i = 0; i < numPoints; i++) { xs[i] = cb.getDouble(); ys[i] = cb.getDouble(); } return new PolyData(xs, ys, startIdx, closed); }PolyData里存的是“大数组 + 分段起止下标”,而不是拆成 N 个小数组。这个设计不是偷懒,是当年做性能优化时被逼出来的:一份数据里有个多段线记录包含 3 万多个 part,每个 part 平均只有 2 个点,拆成 3 万个长度 2 的小数组,光对象头开销和 GC 压力就够喝一壶,改成切片视图之后内存直接降了一个数量级。业务方拿到startIdx之后按需Arrays.copyOfRange就行,谁关心哪段谁自己拷。
至于 Polygon,字节布局和 PolyLine 完全一致,区别只在语义:第一个 part 是外环,后续 part 是内环(洞)。严格按规范,外环是顺时针、内环是逆时针。你可以用有向面积公式辅助判断:
A = 0.5 * Σ (x[i] * y[i+1] - x[i+1] * y[i])顺时针时 A 为负。但我要提醒你,别太信这个规则。现实数据里方向反掉的 Polygon 一抓一大把,很多工具导出时根本不纠正。稳妥的做法是:按各 parts 的面积绝对值排序,绝对值最大的那个当外环,其余当洞,再根据实际包含关系去判定归属。如果你需要的只是坐标点,那就更简单了,方向压根不用管,按 parts 顺序取出来就行。
3.4 坐标出口结构:别返回 List,用回调更省内存
最后一层是“怎么把坐标交出去”。最直觉的写法是List<PointData> readAll(),几万条以内完全没问题,但一旦上到百万级,光是对象创建和垃圾回收就能把你拖死。我实测过一份约 120 万点的数据,全部转成对象列表,堆峰值接近 400MB,改成回调之后稳定在几十 MB。
做法很简单,定义一个访问者接口,解析器读到一条就调一次,业务方自己决定存不存、存到哪:
public interface ShapeVisitor { void onPoint(int seq, double x, double y); void onPolyline(int seq, double[] xs, double[] ys, int[] partStart); void onPolygon(int seq, double[] xs, double[] ys, int[] partStart); }在主循环里这样用:
try (ShpRecordReader reader = new ShpRecordReader(shpFile, header)) { int seq = 0; while (reader.hasNext()) { byte[] content = reader.nextContent(); ByteBuffer cb = ByteBuffer.wrap(content).order(ByteOrder.LITTLE_ENDIAN); GeometryData g = readGeometry(cb); seq++; if (g instanceof PointData p) { visitor.onPoint(seq, p.x, p.y); } else if (g instanceof PolyData poly) { if (poly.closed) visitor.onPolygon(seq, poly.xs, poly.ys, poly.partStart); else visitor.onPolyline(seq, poly.xs, poly.ys, poly.partStart); } } }如果你用的是 JDK 8,把instanceof模式匹配改成普通强转即可。这个接口看着简陋,但它把“读”和“存”彻底解开了:想导 CSV,onPoint里直接往BufferedWriter写一行;想入库,攒够 5000 条做一次 batch;想统计包围盒,就地更新 min/max 四个变量。同一个解析器,三种用法,零改动。
4. 属性表与投影:不读不报错、读错就全错的隐蔽细节
4.1 dbf 编码:中文乱码的真正来源
坐标读对了,属性全是问号,这大概率不是 dbf 解析写错了,而是编码猜错了。dbf 是上世纪的产物,字符编码不是写在显眼位置的:文件头第 29 字节有一个语言驱动 ID(LDID),理论上能查表推出编码,但实际覆盖率极差,很多生产工具干脆填 0,什么信息都不给。
这时候有两条线索。第一条是同目录下的.cpg文件,内容就一行,比如GBK、UTF-8、CP936,这个最靠谱,有就优先用。第二条是字段描述区里的字段名,如果字段名里有非 ASCII 乱码,基本能判断出原始编码——中文数据里最常见的就两种,GBK(CP936)和 UTF-8。
我的读取顺序是:先找.cpg,没有就看 LDID 查表,还不行就按 GBK 试读,读出来有替换字符再退到 UTF-8。代码上就是new String(bytes, charset),没什么花样,关键在“选哪个字符集”这个判断。另外 dbf 的字符串字段是定长右补空格的,取出来必须trim(),否则你会在数据库里看到一堆尾随空格,导致WHERE name = '某个值'永远查不到。
4.2 prj 与坐标单位:为什么你的点画到了海上
配图的时候点全部堆在某个角落、或者干脆跑到海上去了,九成是投影问题。.prj文件里存的是 WKT,一行文本,你可能看到GEOGCS开头(地理坐标系,单位是度),也可能是PROJCS开头(投影坐标系,单位通常是米)。如果你的数据是投影坐标(数值动辄几十万、几百万),却按经纬度去画,那必然跑到天边;反过来,数据是经纬度(数值范围 -180~180、-90~90),你却把它当米去算距离,结果会小得离谱。
一个不用引入任何库的快速自检方法是看文件头里的包围盒。我在ShpHeader里特意保留了minX/minY/maxX/maxY,就是为了这个。读完之后先打一行日志:如果maxX > 180或者minX < -180,那它肯定不是经纬度;如果四个值都在几十到几百万之间,那基本是投影坐标,单位大概率是米或者英尺。这一行日志帮我省掉过无数次“为什么地图上什么都没有”的排查。
还有一个隐蔽陷阱:有些数据的
.prj写的是地理坐标系,但实际数值已经被某个工具投影过了,文件头和 prj 对不上。这种只能靠包围盒范围反推,别迷信 prj 里那行文本。
5. 踩坑实录:五类高频异常的现象、根因与修法
5.1 排查速查表,照着对号入座
这些年我记录下来的问题,基本都逃不出下面几类。我把现象、根因和解法整理成表,遇到问题先扫一眼,比从头调试快得多。
| 现象 | 最可能的根因 | 修法 |
|---|---|---|
| 版本号是天文数字,包围盒是 4.9E-324 | 字节序没切,全程按大端读了 | 28 字节之后统一order(LITTLE_ENDIAN) |
| 只读到前几条记录就结束 | 用了available() > 0做循环条件 | 改用文件头总长度累加消耗字节数 |
| 第一条记录正常,后续全乱 | 记录内容长度没乘 2 | contentLenBytes = 读到的 int × 2 |
| 线被拆成很多小段,首尾不相连 | 没处理 Parts,把整条记录当成一条线 | 按 Parts 数组切片,最后一段到 NumPoints 结束 |
| 中文属性全是问号 | 编码判断错误 | 优先读.cpg,无则按 GBK → UTF-8 顺序试 |
| 所有点都在 (0,0) 附近 | Null Shape(类型 0)没跳过 | 遇到类型 0 直接返回 null,别当点解析 |
| 点跑到地图之外 | 坐标系当成另一种解释了 | 看包围盒数值范围判断度/米 |
| 字符串后面一堆空格 | dbf 定长字段没 trim | 取出来统一trim() |
| 读到文件末尾抛 EOFException | 记录数按 shx 算的和实际不符 | 循环条件以consumed < totalBytes为准 |
这里面我最想强调“Parts 没处理”这一条。很多人在本地测一条简单道路,读出来是对的,上线之后遇到一条多段线的省界或者水系,程序直接把整串坐标连成一条线,地图上立刻出现一条横穿整个图幅的诡异直线。因为 Parts 里相邻两段的端点之间被错误地连起来了。所以只要你的数据里有任何“分段”语义,都必须按startIdx切片。
5.2 大文件与性能:流式读取的几个实操参数
性能上我踩过最深的坑是缓冲区大小。默认FileInputStream是逐字节读的,配上DataInputStream之后每次readInt四个系统调用,读十万条记录能卡到几十秒。加上BufferedInputStream之后立竿见影。缓冲区大小我一般设1 << 16(64KB),试过 8KB 到 1MB 之间的几个档位,64KB 到 256KB 之间差异其实不大,再往上收益就平了,反而占内存。
第二个参数是批量读取的粒度。我现在不逐条readFully小数组,而是一次性把多条记录(比如 2000 条或者凑满 4MB)读进一个大byte[],然后在内存里切。这样系统调用次数从“记录数”降到“批次数的量级”。当然这会让代码复杂一些,如果不是百万级数据,逐条读已经够了,别过早优化。
第三个是坐标数组的复用。如果你的业务允许,onPolyline里接收到的xs/ys若是解析器内部复用的缓冲区,就能把分配次数降到接近零。但这个约定必须在接口注释里写死——业务方只能在回调期间使用这两个数组,出了回调就失效。这种“借出即失效”的设计在性能敏感的解析器里很常见,但一旦忘了写清楚,接手的同事在回调外面存了个引用,后面就会读到别人的数据,排查起来极其痛苦。
一个不那么显眼但很实用的优化点:读点的时候用
ByteBuffer.getDouble()走的是 JVM 内联的路径,比DataInputStream加reverseBytes的组合快不少。我做过对比,一百万次读取大约快 15% 到 20%,数据量大的时候值得改。
6. 我在实际项目中攒下的几条经验
最后分享几条不太写进文档、但确实省事的心得。
第一条是关于“先验证再解析”。我现在所有解析器入口都强制校验两个东西:文件码是不是 9994,文件头里的总长度和File.length()能不能对上。第二条尤其有用——如果文件在传输中被截断,头里的长度会比实际大,你在读到最后几条记录时才抛异常;提前比一下,就能在第一时间给出“文件不完整,请重新获取”这种有用的提示,而不是让业务方对着一个EOFException猜。
第二个是坐标系别自己猜。我见过太多“先用经纬度试一下、不对再换投影”的代码,最后要么漏了数据、要么在某个边界情况下出了错,然后没人说得清到底怎么回事。正确做法是把.prj的内容原样存下来,连同包围盒一起入库,读取的时候先看范围再决定怎么解释数值。哪怕你暂时不需要做坐标转换,把 prj 留档也是零成本的事。
第三个是关于测试数据。别拿线上那份几十万条的数据做单元测试,跑得慢还会挡住 CI。我自己的习惯是手工构造一个“迷你 shp”:一个点、一条两段的折线、一个带洞的多边形,加起来不到 200 字节,硬编码进测试类的byte[]里。这份数据覆盖了大小端、Parts 切片、环方向、空几何四种情况,跑一次不到十毫秒。破解版本升级、重构代码之后,这套测试基本能第一时间告诉你哪块逻辑崩了。
最后说一个容易被忽略的扩展方向:这套解析器把“读字节”和“理解几何”分开了,往里加 Z 值支持其实只要在readGeometry里补几个case,因为 Z 段的布局和 Points 段完全平行——读完 XY 之后紧接着是 Zmin/Zmax,然后是一串 Z 值,再是 M 段。我第一次加 Z 支持的时候只改了不到三十行,业务层的回调签名都没动,只是多传了一个double[] zs参数。分层的收益,在这种“需求临时加码”的时刻体现得最明显。