☰
超图 iServer REST 服务实战:分页、统计与空间过滤避坑
2026/10/1 1:29:26 网站建设 项目流程

接手一个园区运行监测面板的时候,我在分页查询上栽了个跟头:列表翻到第二页,第一页的三条记录又冒了出来。排查了半天,问题既不在后端,也不在表格组件,而是我在调用超图 iServer 的 REST 服务时,把startRecord当成了"页码"来传。这类事情在 GIS 项目里特别常见——地图服务加载、要素分页查询、分组统计、空间条件过滤这四件事看起来各管一段,实际上共用同一套查询参数模型,任何一个参数理解偏了,后面的列表、图表、图面高亮会一起歪。

这篇是我在几个园区管理和客流监测类项目里攒下来的实操记录,围绕超图 iServer 的 REST 服务和 iClient 前端库,把"服务怎么接进来、分页怎么翻、统计让谁算、空间条件怎么筛"这几件事从头拆一遍。前半段适合刚接触超图 REST 接口、能看懂 JavaScript 的同学照着抄;后半段的参数取舍、性能账和踩坑清单,做过两三个项目的人看会更有共鸣。代码以 iClient for Leaflet 和原生 REST 请求为主,iServer 版本之间资源路径有差异的地方,我会明确标出来,你自己在服务列表页对着核对一遍就行。

1. 服务加载这一步,真正决定成败的是地址和资源层级

1.1 把 REST 服务地址拆开看,每一段都在说话

超图 iServer 发布的每一个服务,都会暴露一棵 REST 资源树。很多人接手项目时只拿到一个"地图服务地址"就开干,出问题的时候完全不知道去哪查。我习惯先把地址拆开念一遍:

http://192.168.1.20:8090/iserver/services/map-park/rest/maps/Park

这段地址里,iserver是固定的上下文路径;services表示服务根;map-park是发布时起的服务名,跟你项目里的业务名没半点关系;rest/maps是资源类别;最后的Park是地图名,一个地图服务里可以挂多张地图,切错名字就是空白图。数据服务同理,把/maps/换成/data/datasources/数据源名/datasets/数据集名就能定位到具体数据集。

我的习惯是:拿到地址先去浏览器里把.../rest或.../rest/data打开,iServer 会返回一份资源列表页,里面列着这个服务下所有能访问的子资源,包括要素查询、字段统计、坐标转换等等。这份列表比任何文档都准,因为它就是你当前这台服务器、当前这个版本的实际情况。地址拼错了、服务名记错了、数据源名字带了大小写差异,在这一页上都能立刻发现,比在前端控制台里翻 404 快得多。

1.2 地图白屏,九成问题出在这四个地方

第一次接服务的时候,白屏几乎是必修课。我把遇到过的原因归成四类,按排查成本从低到高排:

现象常见原因快速验证方式
控制台有 404地图名或服务名写错,或者服务压根没启动浏览器直接打开服务地址看是否返回 JSON
有响应但图不出坐标系不匹配,图层范围和底图对不上看返回里的坐标系标识,对比底图坐标系
图出了但错位前端容器用了非 Web 墨卡托投影的切图确认切图类型,经纬度直投的图不要套墨卡托底图
一片空白且无报错容器高度为 0,或者初始化时机早于 DOM 渲染给容器写死高度,初始化放进mounted或DOMContentLoaded

第二条最容易被忽略。项目里如果同时有经纬度直投的地图服务(常见于内部业务数据)和标准的 Web 墨卡托底图,两个图层放同一个视图里必然错位,因为它们的投影基准根本不同。这种时候要么统一换成同一投影的服务,要么各自放在不同的地图实例里做联动,别硬叠。

1.3 最小可运行的地图加载代码

iClient for Leaflet 的写法其实很短,关键是别一上来就写一大堆业务逻辑,先让图出来:

import L from 'leaflet'; import '@supermap/iclient-leaflet'; const mapUrl = 'http://192.168.1.20:8090/iserver/services/map-park/rest/maps/Park'; const map = L.map('map', { center: [31.23, 121.47], zoom: 13, crs: L.CRS.EPSG4326 // 与服务的坐标系保持一致 }); L.supermap.tiledMapLayer(mapUrl, { noWrap: true, // 关闭世界循环,避免跨 180 度出现重复图 transparent: true }).addTo(map);

crs这一项我强烈建议显式写出来。Leaflet 默认是EPSG3857,如果服务本身是 4326 且切图方式不是墨卡托,默认值会让图上出现一种"看起来正常但整体偏移几百米"的错觉,特别难查。另外noWrap在小比例尺下能省掉很多莫名的瓦片请求,图层少的时候无所谓,图层一多流量差别很明显。

图层加载完之后,第一件事是调map.getSize()确认尺寸,第二件事是打开浏览器网络面板,看瓦片请求是不是按行列号规律地发。如果请求地址里的行列号是乱跳的,说明图层范围和视图范围对不上,这时候再往下写业务就是浪费时间。

2. 分页查询:先搞清楚 startRecord 的语义,再谈优化

2.1 startRecord 是偏移量,不是页码

这是我最想提醒的一点。超图 REST 的要素查询参数里,startRecord表示"从第几条记录开始取",是零基偏移量,不是第几页。所以第 1 页传0,第 2 页传pageSize × 1,第 n 页传pageSize × (n - 1)。我当初按页码传,第 2 页传了2,等于从第 3 条开始取,前两条自然就"重复"出现在第 2 页里了。

对应的另一个参数是expectCount,表示期望返回的记录数,也就是页大小。这两个参数配合起来才是完整的"翻页"。expectCount不要随手写个很大的数——比如一次取 5000 条,服务端序列化要时间,网络传输要时间,前端还得把 5000 个要素对象塞进内存并渲染到表格里,这三步里任何一步都可能把页面卡死。我的经验值是表格展示控制在 20 到 50 之间,地图高亮控制在 200 到 500 之间,超过这个量级就该换成聚合展示或者矢量切片思路了。

2.2 totalCount 和 featureCount 不是一回事

查询结果返回体里通常有两个数:featureCount是本次实际返回的条数,totalCount是满足条件的总条数。分页器的总页数必须用totalCount去除以页大小,用featureCount算出来的页数永远只有一页。

{ "featureCount": 20, "totalCount": 317, "features": [ /* ... */ ] }

这里有个实战细节:totalCount是这次查询带出来的,如果页面是"先翻页再看筛选条件",那么每次翻页都要重新算一次总数,服务端会多跑一次计数。数据量大的时候,这个计数本身可能就是瓶颈。我的做法是:条件不变的情况下,总数只取第一次,翻页时前端缓存住;一旦筛选条件、空间范围变了,才重新请求一次完整查询。这么改之后,一个典型的"翻页 10 次"的用户行为,服务端实际只执行了 1 次计数加 10 次取数,而不是 11 次计数。

2.3 深分页的账:到几千条以后就别硬翻了

偏移量分页有个天然的物理限制:数据库要先把前面startRecord条扫过去再丢掉。翻到第 500 页、页大小 50,意味着要跳过 25000 条记录。要素表还带着几何字段,扫描成本比普通业务表高不少。

我在实际项目里试过三条路,按适用场景整理成对照:

方案适用场景代价
偏移量分页结果集小于几千条,页码跳转需求明确深分页变慢,页码越大越慢
游标式翻页只要"下一页/上一页",不跳页排序字段必须唯一且稳定
服务端聚合 + 前端只取聚合结果用户实际只关心分布,不关心明细需要设计合理的分组维度

第三条往往是最容易被忽视的。很多面板上的"台账",产品嘴上说要全量列表,实际用户只盯着分类汇总和异常项。先把需求问清楚,再决定要不要做深分页,比先做出来再优化省事得多。

2.4 一个可以直接抄的分页查询封装

下面这个封装我用了好几个项目,核心是把偏移量算清楚,并且把总数字段单独暴露出来:

async function queryByPage({ baseUrl, datasetName, filter, page = 1, pageSize = 20, orderBy }) { const body = { getFeatureMode: 'SQL', datasetNames: [datasetName], queryParameter: { name: datasetName, attributeFilter: filter || '1=1', fields: ['SMID', 'NAME', 'STATUS', 'ADDRESS'], orderBy: orderBy || 'SMID ASC', startRecord: (page - 1) * pageSize, // 关键:偏移量 expectCount: pageSize } }; const res = await fetch(`${baseUrl}/rest/data/featureResults.rjson`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`查询失败:${res.status}`); const data = await res.json(); return { rows: data.features || [], total: data.totalCount || 0, pageCount: Math.ceil((data.totalCount || 0) / pageSize) }; }

两个注意点。一是attributeFilter不要传空字符串,服务端对空串的处理在不同版本里不一致,我统一用1=1这种恒真条件兜底,最省事。二是orderBy必须给。没有明确排序的查询,翻页时记录的相对顺序可能变化,同一批数据在两次请求里顺序不一致,用户就会看到"翻页后有条目消失又出现"的现象——这不是分页 bug,是没排序。

3. 统计:让服务端算完再传,别把几万条拉回来自己数

3.1 先问清楚:这个数字该由谁算

面板上出现"本月客流总量""某类设施数量"这种数字时,第一反应往往是"查出来再前端 reduce 一下"。小数据量没问题,数据量一上来,这么做就是拿网络和内存换省事。一条要素如果带几何,序列化出来的 JSON 少则几百字节,几万条就是几兆到几十兆。

判断标准很简单:这个数字是不是要展示明细行。不需要明细的,走服务端聚合;需要明细顺带一个合计的,可以只取当前页做局部合计,并在界面上注明"当前页合计"。把口径写清楚,比算错一个数让业务方来找你强得多。

3.2 用 SQL 聚合加分组做分组统计

服务端的 SQL 查询支持聚合表达式和分组子句的组合,基本形态是:fields里既有分组字段也有聚合函数,再配一个分组子句。这么做的好处是无论底层有多少条记录,返回的只有若干个分组行。

const statBody = { getFeatureMode: 'SQL', datasetNames: ['Park:Facilities'], queryParameter: { name: 'Park:Facilities', attributeFilter: "STATUS = 1", fields: ['TYPE', 'COUNT(*) AS CNT', 'SUM(AREA) AS TOTAL_AREA'], groupClause: 'TYPE', orderBy: 'CNT DESC' } };

返回结果就是每个TYPE一行,带数量和面积合计。前端拿到直接喂给图表库,不需要任何二次计算。

这里有个必须提醒的地方:不同 iServer 版本对聚合表达式和别名的支持程度不一样。有的版本对AS 别名解析很友好,有的版本返回的字段名会是原样的表达式字符串。我的做法是先手工发一次请求,把返回的字段名看清楚,再在前端做一层字段名映射,不要硬编码CNT。另外如果聚合字段涉及浮点,合计会有精度尾数,展示时统一toFixed(2),别让业务方看到1234.5600000000001。

3.3 只要行数:计数接口比取数据快一个数量级

如果只是要"有多少条",不要去取要素。要素计数接口返回的只有一个数字,不需要序列化几何、不需要传输属性,速度差得非常明显。iClient 里对应的参数类很直接:

const countParam = new SuperMap.GetFeatureCountParameters({ queryParams: [ new SuperMap.FilterParameter({ name: 'Park:Facilities', attributeFilter: "TYPE = '停车'" }) ] }); new SuperMap.FeatureService(dataUrl).getFeatureCount(countParam, (serviceResult) => { if (serviceResult && serviceResult.result) { console.log('停车设施总数:', serviceResult.result); } });

一个统计面板上如果有 6 个指标卡,每个都去拉全量数据,页面首屏就要发 6 次重查询。改成 6 次计数之后,首屏时间从几秒降到几百毫秒,这是我在一个客流监测项目里实测过的改动,收益非常直观。

3.4 统计口径里最容易错的三个地方

第一,空值参与聚合。如果某个字段有空值,COUNT(字段)会跳过空值,而COUNT(*)不会。做设施统计的时候如果按字段计数,容易少算。保险做法是先把空值的处理逻辑写进筛选条件,或者在展示层注明"已排除未填报项"。

第二,重复计数。空间过滤叠加之后,同一条要素可能同时命中多个条件,尤其是做缓冲区与行政区域叠加分析时。如果业务口径是"去重的设施数量",那就要按主键去重,而不是把两次查询的结果相加。

第三,坐标系与面积单位。面积统计特别容易出错。如果数据是经纬度坐标,直接算出来的面积单位是"平方度",没有任何业务意义。要算平方米或平方公里,必须换成投影坐标系,或者用服务端带的地理计算能力。这一点我在做绿化覆盖率统计时吃过亏,图上看不出问题,数字差了几十倍。

4. 空间条件过滤:谓词选错,结果就完全不是那个意思

4.1 空间关系谓词之间到底差在哪

空间过滤的核心是选对关系谓词。常用的几个我按语义整理一下:

  • 相交(INTERSECT):两个几何有任何公共部分就算命中。最宽松,用得最多。
  • 包含于(WITHIN):要素完全落在查询几何内部。适合"这个园区里有哪些设施"。
  • 包含(CONTAIN):反过来,查询几何完全落在要素内部。适合"这个点落在哪个片区里"。
  • 相离(DISJOINT):完全不接触。适合做排除。
  • 相接(TOUCH):只有边界接触,内部不相交。适合邻接关系分析。

最容易混淆的是 WITHIN 和 INTERSECT。用"园区边界"去查设施,如果园区边界恰好有一个设施压在线上,INTERSECT 会把它算进来,WITHIN 不会。业务方要"园区内设施台账"时,到底是算不算边界上的,这是个必须当场问清楚的问题。我的习惯是把谓词做成配置项暴露给业务侧确认,而不是埋在代码里。

4.2 三种真实场景里的过滤构造

场景一:矩形框选。用户在地图上拖一个框,查框内设施。这种最直接,拿框的四个角构造多边形,谓词用相交。

场景二:任意多边形圈选。用户手绘区域,比如把某个商圈圈出来分析客流。这里要注意多边形是否自相交——自相交的多边形有些后端会直接报错,或者返回意料之外的结果。前端做一层简单的自相交检测,或者在交互上限制点数,都能减少麻烦。

场景三:缓冲区分析。以某个点或线为中心,按距离向外扩展一圈再来查。这在选址、覆盖分析里用得极多。缓冲区查询的参数里除了几何对象,还有一个距离值。

const geoParam = new SuperMap.QueryByGeometryParameters({ queryParams: [ new SuperMap.FilterParameter({ name: 'Park:BusStops', fields: ['SMID', 'NAME', 'LINE_NO'] }) ], geometry: drawnPolygon, spatialQueryMode: 'INTERSECT' }); new SuperMap.FeatureService(dataUrl).queryByGeometry(geoParam, (serviceResult) => { const features = (serviceResult && serviceResult.result && serviceResult.result.features) || []; /* 画点到地图、刷新表格 */ });

缓冲区那类查询在 iClient 里对应的是按距离查询的参数类,带distance字段。这段代码的关键不是语法,而是它把"过滤在地图上画"和"查询条件"直接绑定了,用户画什么就查什么,中间不加任何隐含条件。这一点在交付时特别重要,因为业务方会反复问"这个范围是怎么来的",能指着地图回答的,比翻代码回答的靠谱。

4.3 缓冲区半径的单位陷阱

这是个大坑。距离参数的默认单位跟数据集的坐标系强相关:经纬度坐标下,距离单位通常是度;投影坐标下才是米。我见过同事按 500 米传了个 500 进去,结果查出来把周边几个省的数据全捞回来了——因为 500 度差不多绕地球好几圈。

处理方式有三种,我的推荐顺序是这样的:优先把数据集统一到投影坐标系,直接按米传;如果不能改数据,就用服务端的坐标转换能力,把缓冲区半径按当前纬度换算成度数,注意这个换算在不同纬度上不一样,不能用一个固定系数;实在不行,前端按近似公式换算并明确标注"示意范围"。无论走哪条,都要在界面上把单位标出来,别让用户猜。

4.4 属性过滤和空间过滤叠加时的书写顺序

两个条件一起用是很常见的需求,比如"查这个范围内还在营业的停车设施"。书写上没什么顺序要求,但有个容易被忽略的点:先写条件范围小的那个。如果属性过滤能筛掉 95% 的数据,那空间过滤待处理的数据量就小得多,整体响应会明显改善。

还有一个细节是空间过滤和分页的关系。空间过滤之后再做分页,分页是对过滤后的结果集分页,这一点符合直觉,但如果前端把空间条件和分页参数拆成两次请求,就可能出现"页码没重置"的问题——用户画了个小范围,结果集只有 8 条,但页码还停在第 5 页,页面直接空白。我的处理方式是:任何会导致结果集变化的操作(换筛选条件、改空间范围、改排序),一律把页码重置为 1。

5. 把加载、分页、统计、空间过滤串成一条业务链

5.1 场景定义:一张"可见范围要素台账"

假设有这么一个面板:左侧是地图,右侧是表格加三个指标卡。用户缩放或拖动地图,表格自动刷新为当前可见范围内的设施,指标卡显示当前范围内的总数、营业中和已停用三项。这个需求把前面四件事全用上了,很适合当综合练习。

拆解一下数据流:地图移动结束产生事件,从当前视图范围拿一个边界框,把这个框作为空间条件去查询,同时再发一组计数请求给指标卡。表格走分页接口,指标卡走计数接口。两条线共用同一个空间条件,只是返回内容不同。

5.2 地图事件到查询参数的映射

地图实例上监听移动结束事件,从视图里取边界,转成查询用的几何对象:

map.on('moveend', () => { const bounds = map.getBounds(); const bbox = { type: 'Polygon', coordinates: [[ [bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()], [bounds.getWest(), bounds.getNorth()], [bounds.getWest(), bounds.getSouth()] ]] }; // 空间条件变了,分页一律重置到第 1 页 state.page = 1; state.spatialFilter = bbox; refreshTable(); refreshStatCards(); });

注意多边形要闭合,首尾坐标点必须一致,我见过因为少写一个点导致查询直接返回空的情况,报错信息还很不明显,排查花了不少时间。另外边界框在跨 180 度经线时会出问题,如果项目涉及这种情况,得单独做分割处理,一般园区的项目用不到,但心里要有数。

5.3 节流、合并与缓存:别让一次拖动打爆服务

用户拖动地图的时候,moveend会被触发得非常频繁。如果不做处理,一次拖拽可能发出十几个查询请求,服务端压力大,前端还要处理乱序返回——后发的请求先回来,表格内容就变成了旧范围的数据。

我的处理方式是三层:第一层节流,地图移动结束后延迟 300 毫秒再发请求,中途再次移动就取消;第二层请求取消,用中断控制器把上一次未完成的请求中止掉;第三层结果缓存,同样的空间范围和筛选条件在短时间内重复出现时直接读缓存。三层加起来的代码量不大,但对用户体验的改善非常明显。尤其是第三层,用户来回拖动来回比较时,命中缓存的响应几乎是瞬间。

缓存这里有个必须注意的点:数据是会变的。缓存时间不能设太长,我一般设 30 秒到 1 分钟,并且在页面上加一个手动刷新按钮。做过一个项目因为缓存设成了 10 分钟,业务方更新了数据看不到变化,以为系统坏了,这属于自己给自己找麻烦。

5.4 前后端字段约定,最好落成一张表

接口联调阶段最耗时的从来不是逻辑,而是字段名对不上。我现在的做法是在开工前就把约定写成一张表,前后端各留一份:

用途请求侧名字返回侧名字备注
页大小pageSize-前端参数,映射到 expectCount
当前页page-前端参数,换算成 startRecord
总条数-totalCount分页器总页数用它算
本页条数-featureCount只用于调试和日志
唯一标识-SMID前端做 key,别用数组下标
空间范围spatialFilter-统一用 GeoJSON 多边形

这张表看起来啰嗦,但它把"startRecord 是偏移量"这类隐含知识显性化了。新人接手时看表就能写对,不用再踩一遍我踩过的坑。

6. 这些坑我踩过不止一次,提前说给你听

6.1 数据集没建空间索引,空间过滤会慢到离谱

属性查询和空间查询的代价完全不是一个量级。属性查询在有索引的字段上很快,空间查询如果没有空间索引,服务端只能逐条比对几何关系,几万条数据就能让响应时间从几十毫秒涨到几秒。

判断方法很直接:同一条空间范围查询,换个小范围再试一次。如果范围缩小一半、耗时几乎不变,那基本就是全表扫描了。解决办法是在数据准备阶段就给数据集建空间索引,这一步在数据入库时做最省事,事后补建也可以,只是要注意补建期间不要做写操作。

6.2 返回字段的大小写和类型,别想当然

不同版本、不同数据源类型,返回的字段名大小写可能不一致。同一个字段,这个服务返回NAME,换个服务可能返回name。前端如果直接row.NAME,换个环境就取不到值,而且不报错,只是显示空白,特别隐蔽。

我的做法是拿到数据后先跑一次字段名归一化,把所有键统一处理成小写或大写再进业务逻辑。另外数值字段的类型也要留意,有时候返回的是字符串,直接参与排序或相加会出问题,该转的转一下。

6.3 空几何和异常值,会毁掉整条渲染链

数据里偶尔会有几何为空、坐标点为 0 或者坐标值明显超范围的记录。这些记录绘到地图上,轻则在地图角落出现一个孤点,重则让整个图层的渲染出错。前端在绘制前做一次坐标有效性过滤,成本很低,收益很高。同理,表格渲染时对null值统一显示成-,比显示null或空白友好得多。

6.4 出问题时的自查清单

我现在遇到查询类问题,基本按这个顺序过一遍,很少需要打电话问人:

顺序检查项典型症状
1服务地址能否在浏览器直接打开打不开说明是服务或网络问题
2数据集名和字段名是否与列表页一致报字段不存在
3startRecord 是否为偏移量、页码是否重置翻页重复或空白
4排序字段是否唯一且稳定翻页结果顺序跳动
5空间几何是否闭合、坐标是否有效空间查询返回空
6距离参数单位是否与坐标系匹配缓冲区范围大得离谱
7是否命中了缓存数据更新后看不到变化

第 7 条看起来最不起眼,实际出现频率不低。我现在的习惯是开发环境下默认关闭缓存,只在预发和生产开,避免自己骗自己。

最后分享一个我觉得挺有用的小技巧:在开发阶段给自己加一个调试面板,把每次请求的完整参数和返回的条数打在角落里。这个东西花不了多少时间,但排查问题时能省下大量来回。我在一个客流统计面板上加了它之后,绝大多数"数据不对"的反馈,看一眼参数就知道是筛选条件传错了还是服务端返回的问题,不用再靠猜。这套东西搬到其他统计类项目上,基本能直接复用,唯一需要调整的只是字段映射那一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询