多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

超图 iServer REST 服务实战:分页、统计与空间过滤避坑

超图 iServer REST 服务实战:分页、统计与空间过滤避坑 接手一个园区运行监测面板的时候我在分页查询上栽了个跟头列表翻到第二页第一页的三条记录又冒了出来。排查了半天问题既不在后端也不在表格组件而是我在调用超图 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 || 11, 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不要传空字符串服务端对空串的处理在不同版本里不一致我统一用11这种恒真条件兜底最省事。二是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 条看起来最不起眼实际出现频率不低。我现在的习惯是开发环境下默认关闭缓存只在预发和生产开避免自己骗自己。最后分享一个我觉得挺有用的小技巧在开发阶段给自己加一个调试面板把每次请求的完整参数和返回的条数打在角落里。这个东西花不了多少时间但排查问题时能省下大量来回。我在一个客流统计面板上加了它之后绝大多数数据不对的反馈看一眼参数就知道是筛选条件传错了还是服务端返回的问题不用再靠猜。这套东西搬到其他统计类项目上基本能直接复用唯一需要调整的只是字段映射那一层。
返回列表