
1. 内容整体设计与思路拆解1.1 为什么SMP需要一套脚本语言做GBISP通用业务信息平台做到第四期我越来越觉得SMP软件制作平台这套内置脚本语言才是整个平台的灵魂。GBISP负责把订单、客户、库存、审批这些业务数据统一管起来但数据放在那里不动它只是记录。真正让业务跑起来的是SMP里那些用脚本写出来的逻辑订单超时自动提醒、库存不足自动锁单、对账单按客户分组汇总、报表按部门权限自动裁剪数据……这些动作如果用传统开发方式做每来一个新需求就要改代码、发版、重启业务等不起。SMP的价值就是让开发者甚至懂一点逻辑的业务人员在界面上直接写脚本、绑事件、跑流程改完立即生效不用动平台本身。到了第七十六篇“语言基础知识”我们已经从变量、类型、分支循环、函数定义一路讲到了模块化组织和异常兜底。今天这篇聊的是数据集操作——也就是SMP脚本里对二维表数据行和列做筛选、排序、分组、统计、关联和分页的那套方法集。为什么把它单独拎出来当一篇“基础知识”因为GBISP里百分之八十的业务场景本质上都是“从一堆数据里算出想要的那一小撮”。搞懂数据集操作等于拿到了整个平台最值钱的那把钥匙。1.2 这篇适合谁读如果你已经在用SMP搭过几个简单的信息管理页面对表单、列表、详情页这些基础构件有概念但遇到复杂一点的统计需求就只能在脚本里写一堆for循环硬算那这篇就是写给你的。它不需要你有计算机科班背景但最好已经读过这个系列前面几篇关于变量、条件判断、循环和函数的内容——因为数据集操作底层还是那些东西只是换成了一批更顺手的接口。我接手过一个典型的场景某公司内部的服务工单系统GBISP里数据表每天新增几千条工单记录运营要看“各小组本周平均响应时长、超时率、按优先级分布”。最开始实现方式是写脚本全表遍历一边循环一边if判断分组再逐个累加和计数。数据量小时没问题可等工单一多页面打开要等十来秒运营直接投诉。后来我把那段逻辑全部换成SMP的数据集操作方法集脚本行数缩了一半执行时间缩到原来的十分之一。这种体验才是SMP该有的样子。2. 数据集的基本概念与核心操作定位2.1 数据集在SMP里到底是什么在SMP脚本里数据集DataFrame就像一张Excel表格的内存副本有列名有多行数据每一行是一个记录对象。它可以从GBISP的实体表查询出来也可以把表单提交的参数组装成数据集还能由另一个数据集经过操作生成新数据集——这正好对应SQL的“查询结果也是表”的思路。我把数据集操作分成三类整表变换筛选、排序、去重、截取前N条、分页行列计算新增计算列、修改列值、删除列、重命名字段多表组合关联类似LEFT JOIN、纵向合并类似UNION、分组聚合这些操作不带任何界面元素纯粹面向数据计算。好处是页面只负责展示最终结果中间有多少次筛选和计算全部在脚本里完成页面不需要记任何中间状态刷新即重算逻辑始终干净。2.2 为什么强调“链式调用”SMP脚本里的数据集操作基本都是“每步都返回新数据集”的写法这是刻意的设计。比如var ds DB.query(select * from order_info where status ! CANCELLED); var result ds .filter(function(row) { return row.amount 1000; }) .sortBy(order_time, false) .limit(50) .calcFields({ totalWithTax: function(row) { return row.amount * 1.13; } }) .page(1, 20);每一步都不改动原数据而是生成一个新数据集给下一步。写起来像一个流水线读起来也像——从查询结果开始先过滤掉小额订单再按时排序截前50条然后加一列含税金额最后取第一页20条。想在哪一步加一个调试输出就在那个位置插入一行打印不会破坏前后逻辑。这种链式风格还顺带解决了一个大坑原数据复用。如果不小心在某个环节把原数据集改了后面想再取原始数据就得重新查库。SMP强制每一步生成新数据集则可以放心地让同一个原始数据集被多条计算链路同时使用——比如一份订单数据一条链路统计月度趋势另一条链路做客户排行榜互不干扰。2.3 和SQL的关系SMP脚本该不该写SQL经常有人问我“GBISP底层就是关系型数据库我直接用SQL不是更熟吗为什么还要学封装好的数据集方法”我的回答是SQL当然可以写而且有些复杂查询用SQL一步到位效率最高。SMP脚本的DB.query()方法也支持直接传完整SQL语句这在需要复杂多表关联和数据库端聚合时非常省事。但问题在于GBISP里很多数据不是直接从数据库来的。比如页面表单收集到的多条明细、流程引擎传过来的审批意见列表、外部接口返回的JSON数组……这些数据在内存里是结构化的行集但根本不在数据库里。你没法对它们写SQL只能靠SMP数据集方法在内存中做变换。另一个场景是条件动态变化。写死SQL很容易难的是SQL片段要跟着前端选择的条件动态拼。早期我见过有人用字符串拼接SQL一旦用户输入带引号或百分号就会出各种奇怪问题——要么查询结果不对要么整个页面报错。SMP数据集方法则完全在内存里操作没有SQL注入这层风险条件分支也很自然。所以我的经验是跨表复杂聚合、大表按索引快速过滤用SQL内存里的多步加工、条件动态组合、步骤可调试、逻辑需要复用用SMP数据集方法。两者配合SMP脚本写起来才顺手。3. 数据集核心方法详解与实操要点3.1 筛选filter的用法与常见误解筛选是数据集操作里最常用的动作作用是从一个数据集中挑出满足条件的行返回新数据集。SMP脚本里filter的基本写法有两种。第一种是传入条件对象var urgentOrders allOrders.filter({ priority: HIGH, status: OPEN });上面这个写法是“同时满足”语义相当于SQL里的AND。第二种是传入函数函数接收每一行记录返回true表示保留var bigOrders allOrders.filter(function(row) { return row.amount 5000 row.customerType ! INTERNAL; });函数写法灵活可以做范围判断、取反、字符串匹配甚至调用其他函数做复杂计算。我在实际项目中经常遇到这样的需求筛选订单金额大于某个变量值、且客户名称匹配某个关键字。用条件对象写不出来用函数一行搞定。写filter要注意三个坑第一筛选条件里的字段名必须是数据集里真实存在的列名大小写也要一致。SMP对列名是大小写敏感的比如列名定义成了order_time你过滤器里写orderTime直接取不到值返回undefined条件判定自然出错。第二null值处理要主动。业务数据经常有空值尤其是金额字段。如果你写成return row.amount 1000;那么amount为null的行null 1000的结果是false在SMP里比较运算对null返回false会被正常过滤掉一般符合预期。但如果你的需求是“金额为空也要保留”就要显式写return row.amount null || row.amount 1000;否则很容易把本该保留的行丢掉而且排查起来还不能一眼看到——因为页面上没数据时你根本不知道是查询条件本身排除了它还是根本没查出来。第三filter不会改变原始数据集这一点我前面强调过但还是要再说一遍。有人习惯在filter之后继续用原数据集的变量名var ds DB.query(select * from orders); ds ds.filter(function(row) { return row.amount 0; });这样写没问题因为重新赋给了同一个变量。但如果你在多个地方引用了最开始的ds就要小心赋值覆盖后其他地方的数据变了。我更倾向于用不同变量名接收每次操作的结果比如sourceDs、afterFilter、afterSort清晰得多。3.2 排序与截断sortBy、limit、page的使用和性能思考排序列是用户最常感知的操作。SMP里sortBy接收两个参数列名和是否降序排列。比如var sorted ds.sortBy(createdAt, false); // 按创建时间倒序多个列排序需要连续调用或者查一下SMP版本是否支持多列参数写法。我用的版本支持传入数组var sorted ds.sortBy([status, createdAt], [true, false]); // 先按status升序再按createdAt降序排序性能在这里要特别提醒一下sortBy是在内存里全量排序的。数据量大比如超过十万行时全量排序会有可见的耗时。如果你最终只需要前20条先排序再截断是必要的——因为不排序没法保证“取最新20条”。但如果你不需要排序就别加省一次全量排序的时间。如果数据量稳定在几十万行以上建议在数据库查询阶段就用ORDER BY把排序做好不要拉全量到内存再排。limit和page都是截断操作。limit(N)取前N行page(n, size)跳过前(n-1)乘size行后取size行用来做分页。我一般习惯先filter再sort最后再limit或page顺序不要乱。如果把limit写在filter前面那过滤后的数据已经不是完整的全集后面的排序和分页都可能得到错误结果——尤其是分页每页数据都是从截断后的子集里再切会造成不同页数据重复或遗漏。有个使用技巧如果数据集要同时输出到“全部数据下载”和“当前页表格”建议从同一个排序好的数据集出发一个分支用page生成表格数据另一个分支直接用全量做导出。不要为导出重新排序因为两次排序的稳定性可能不同用户下载后发现顺序和页面上不一样又得来问。3.3 字段加工calcFields、map与数据清洗calcFields是我用得最多的方法。它给数据集的每一行追加一个或多个新列列的值由函数计算得出。语法var dsWithTax orders.calcFields({ taxAmount: function(row) { return round2(row.amount * 0.13); }, totalAmount: function(row) { return round2(row.amount * 1.13); } });calcFields不修改原始行对象只是在返回的新数据集里增加列需要原始数据时仍然可用原变量。这是SMP很贴心的设计——数据加工流水线上每一步都能追溯。map方法比calcFields更底层它可以对每一行做任意变换包括改原有字段、删掉某些字段甚至改变行的结构。比如把一行订单记录里的客户名和客户编号拼接成一个新的“客户展示名”字段同时把敏感字段剔除掉用map是正解。实际项目里我经常在返回前端展示前用map做数据脱敏和字段裁剪订单详情里有客户手机号但列表页只需要显示后四位内部备注字段不能让普通用户看到。如果用SQL直接查出来原样返回前端就得兜底处理很容易漏。SMP脚本里把这些逻辑统一做掉接口返回什么前端就显示什么权限边界清清楚楚。字段加工碰到的坑主要是类型。SMP的列值有Number、String、Date、Boolean等类型在做金额计算时一定要确认列类型是Number而不是String。很多数据表导入时把金额列存成了文本比如带着货币符号或千分位逗号这时候直接做加法就会出现类型报错或者更隐蔽的字符串拼接1000 200 1000200。处理办法是在算子前做一次类型清洗function toNum(v) { if (v null) return 0; var s String(v).replace(/[,\s]/g, ); var n parseFloat(s); return isNaN(n) ? 0 : n; }清洗完再参与计算永远不要在业务逻辑里散落一堆parseFloat。同一份清洗函数放公共模块所有脚本共用它就不会再有人因为数据格式不同而算错金额了。3.4 分组聚合groupBy与aggregate的正确打开方式做统计报表时groupBy和aggregate是核心。groupBy按一个或多个字段把数据集拆成若干组aggregate针对每组做聚合计算或输出汇总行。SMP里的常见用法var monthlyStat orders .groupBy(month) .aggregate({ orderCount: { sum: 1 }, totalAmount: { sum: amount }, avgAmount: { avg: amount }, maxAmount: { max: amount } });groupBy参数还能传数组实现多字段分组var cityCateStat orders .groupBy([city, category]) .aggregate({ orderCount: { sum: 1 } });这个逻辑和SQL里的GROUP BY完全对应但好处是不用拼SQL分组字段可以完全动态。前端下拉选了“按城市分组”脚本里就传groupBy(city)选了“按城市品类”就传数组。SMP脚本不用像SQL那样重建语句只改一个参数即可。aggregate的聚合器常用sum、avg、max、min、count个别版本还有stddev、median这类统计函数。需要注意对空值列做avg时SMP默认忽略null行但不忽略0行。如果业务上0和null含义不同预期“不算0的均值”就要在分组前先把0值替换成null或者反过来把null替换成0——按业务语义选一个别让统计结果悄悄跑偏。分组聚合后如果想按聚合结果排序我给一个通用模式var sortedStat monthlyStat.sortBy(totalAmount, false);因为aggregate返回的也是数据集可以接着用前面那些方法。把多个操作串联起来一份脚本就能完成“筛选-分组-聚合-排序-取前N”的完整报表链路。3.5 多表关联与纵向合并join和union的使用场景GBISP里实体表之间经常有关系比如订单表关联客户表、工单表关联处理人表。SMP脚本可以在内存里做join用法var ordersWithCustomer orders.join( customers, customerId, // 左表关联键 id, // 右表关联键 LEFT, // 关联类型 LEFT / INNER / RIGHT { customerName: name, customerLevel: level } );第四个参数指定从右表取哪些字段到结果集里并可以重命名避免两个表都有“name”字段时冲突。join适合小数据量关联几千到几万行如果两个表都是大表还是优先在数据库里用SQL join做完再拉取。内存join的特长场景是数据来自不同渠道——一部分来自数据库一部分来自接口返回的JSON数组还有一部分是流程引擎传过来的临时数据它们不在同一个数据库里只能用脚本来拼。union就是纵向合并两个列结构相同或兼容的数据集。经常用在“本月和上月数据合并对比”、“线上线下渠道合并统计”这些场景。如果两边的列名不完全一致union之前先用map或calcFields把列名对齐否则合并后会出现大量null列。3.6 分页与大数据量场景的取舍策略前面对分页做了基本介绍但大数据量场景还是要专门说一说因为这直接决定页面会不会卡死。SMP的page(n, size)是在内存数据集上切片的。如果底层SQL直接把全量数据拉出来了那数据量再大也是先拉全量、内存占用高、网络传输慢。正确做法是能用数据库分页就在查询阶段分页var pageData DB.query( select * from order_info order by created_at desc limit ? offset ?, [pageSize, (pageNum - 1) * pageSize] );但如果后续还需要做过滤、统计或者原始数据来源比较复杂比如多个数据集join之后的时候就不得不先拉全量再在内存里处理。那时候要控制数据规模在十万行以内超过这个量级建议考虑在GBISP里建中间表定时任务把明细汇总成每日统计表报表画面直接查统计表而不是实时扫明细。我个人踩过最大的坑是“为了一个统计数字把几百MB的明细拉进内存”。后来在两张大表上join再聚合直接跑了十五秒超时。改成在数据库SQL里join和分组后返回的数据集只有几十行速度提升到毫秒级。数据集方法再方便也替代不了数据库在海量数据聚合上的优势。该下沉到SQL的计算别犹豫果断下沉。4. 实操过程与核心环节实现订单月度分析页面4.1 业务目标与准备数据纸上谈兵到这里我用一个完整的实操案例串一遍今天讲的内容。假设GBISP里有两张实体表order_info字段有order_id、customer_id、amount、order_time、status、citycustomer_info字段有id、name、levelVIP/NORMAL、industry业务需求是在GBISP里做一个“月度订单分析页”展示本月总订单数、总金额按城市分组的订单数、金额按客户等级分组的金额占比本月VIP客户订单TOP10列表所有数据能按时间范围筛选4.2 脚本实现与逐步说明我习惯把查询参数放在最前面方便后续调整var startDate 2025-11-01; var endDate 2025-11-30; // 第一步从库里查订单连客户表的客户名和等级 var ds DB.query( select o.order_id, o.customer_id, o.amount, o.order_time, o.city, o.status, c.name as customer_name, c.level as customer_level from order_info o left join customer_info c on o.customer_id c.id where o.order_time ? and o.order_time ? and o.status ! CANCELLED, [startDate, endDate 23:59:59] ); // 第二步过滤掉异常数据金额为负或为空 var clean ds.filter(function(row) { return row.amount ! null row.amount 0; }); // 第三步基础统计 var totalCount clean.count(); var totalAmount clean.aggregate({ totalAmt: { sum: amount } }).get(0).totalAmt; // 第四步按城市分组 var byCity clean .groupBy(city) .aggregate({ orderCount: { sum: 1 }, cityAmount: { sum: amount } }) .sortBy(cityAmount, false); // 第五步按客户等级分组 var byLevel clean .groupBy(customer_level) .aggregate({ levelAmount: { sum: amount } }) .sortBy(levelAmount, false); // 第六步VIP客户订单TOP10先按金额倒序然后取前10 var vipTop10 clean .filter(function(row) { return row.customer_level VIP; }) .sortBy(amount, false) .limit(10) .calcFields({ orderDate: function(row) { return fmtDate(row.order_time); } }); // 输出结果对象供界面绑定 return { totalCount: totalCount, totalAmount: round2(totalAmount), byCity: byCity.toList(), byLevel: byLevel.toList(), vipTop10: vipTop10.toList() };这段脚本集中展示了筛选、聚合、分组、排序、截断和字段加工的组合用法。逻辑从上往下读每一步的输入输出很清晰。4.3 页面绑定与运行效果在SMP里新建一个分析页面放几个统计卡片和两个表格组件数据源分别绑定上面返回的byCity、byLevel和vipTop10。因为脚本返回的是纯数组绑定非常简单界面刷新时脚本自动重算不需要额外写请求逻辑。我本地用模拟数据测过生成两万条订单、五百个客户脚本从查询到输出结果全程在一到两秒内完成页面渲染也流畅。如果数据量再上一个数量级就要考虑让SQL直接做分组聚合脚本只做展示数据的组装。4.4 监控与调试每步都留一手SMP脚本编辑器支持单步打印我强烈建议在正式环境调试时每操作一步就把当前数据集的count和toList打印出来看一眼console.log(after clean:, clean.count()); console.log(byCity:, byCity.toList());这样一旦结果跟预期不一致立刻能定位到是哪一步出了问题。最怕的是几十行脚本写完直接跑结果不对然后从头一行行猜。打印是穷人的调试器也是最可靠的调试器。5. 常见问题与排查技巧实录5.1 字段名大小写与列名不存在问题症状filter或sortBy不生效甚至直接报“column not found”。排查步骤先打印数据集的第一行查看实际列名是什么var first ds.get(0); console.log(Object.keys(first));然后对比你代码里写的字段名。SMP对大小写敏感写order_time就不要写成OrderTime。列名不存在时filter里row.xxx拿到的是undefined比较运算结果全是false安静地丢掉全部数据最迷惑。加上一行打印能立刻看清。另外要注意SQL查询里select子句写的别名就是最终列名。如果你写了“select o.amount as amt”数据集这一列就叫amt不叫amount。在脚本里继续写amount下面所有计算都会出错。养成习惯写完查询先打印一行列名清单。5.2 聚合结果不对是不是把null算进去了场景想统计每月的客户平均下单金额但结果明显偏低。排查发现有大量订单的金额是null被当作0参与了平均计算。业务上“没填金额”和“金额为0”应该区别对待。处理办法分组聚合前先统一空值语义。如果业务认定null应该跳过就先把null行过滤掉如果认定应该当0参与统计就不用管。关键是明确选择并写下来别让维护的人猜。另外aggregate的sum聚合器有没有忽略null不同SMP版本行为不完全一致。稳妥做法是自己先做一次清洗把null转成0或者过滤掉再交给聚合器。自己动手永远比依赖隐式行为稳妥。5.3 join关联出来的数据重复或变多症状join之后数据集行数比左表还多。这是因为右表存在多个匹配行比如一个客户有多个联系方式记录一对多join天然产生重复。排查思路先检查右表的关联键是否有唯一性约束。如果右表确实会重复按业务决定取哪一条通常是用右表过滤只保留每个关联键的最新一条var dedupedCustomers customers .sortBy(updatedAt, false) .groupBy(id) .aggregate({ keepRow: { first: self } });或者更简单在SQL查询里用ROW_NUMBER按关联键排序去重后再join。千万别以为join做出来行数不对就是平台有bug绝大多数时候是数据本身有多对多关系。还有一种情况是关联键本身类型不一致。订单表里customer_id是Number客户表里id是String即使值看起来一样1001和1001join也匹配不上。处理方式是统一类型orders.calcFields({ customerIdStr: function(r) { return String(r.customer_id); } }) .join(customers, customerIdStr, id, LEFT, {...});这种类型不一致的问题在数据导入类系统里非常常见join前检查两边字段类型的习惯要培养起来。5.4 分页数据错乱没有先排序就分页这是新手高频问题。数据集本身的存储顺序是查询结果顺序或数据插入顺序分页只是按这个顺序切片。如果不做排序两次查询之间底层数据如果有新增或修改同一页可能看到不同的数据用户会以为系统“串行”了。我的习惯是任何分页查询之前先做一次稳定排序哪怕只是按主键升序。这样分页的边界是可预期的也方便排查。主键是自增数字时按它排序通常不会错。顺便提一下两个不同时间执行的分页请求到底会不会串数据取决于底层查询是否每次都重新执行。SMP脚本默认每次打开页面都重新跑一遍所以数据会波动是正常的。要固定的话可以把排序键做成可配置的让用户自己决定按什么排序而不是用默认顺序糊弄过去。5.5 内存溢出或脚本执行超时症状数据集操作在数据量大时一直转圈甚至报超时。处理步骤先确认数据量到底多大打印ds.count()。再确认哪些操作耗内存最常见的是全量sortBy和join。排序尽量下沉到SQL的ORDER BYjoin尽量两边都小。如果确实需要内存处理大数据考虑分批比如按月分批处理再把结果合并成新数据集。我在某次做历史数据迁移脚本时把三年两百万条明细一次性拉进内存页面直接崩溃。改成在SQL里按月分组查询只把十二个月的汇总结果拉回来整个批量任务只用了不到一分钟。还有一个容易忽略的点calcFields和map里如果调用了外部接口或耗时函数每一行都执行一遍行数多时时间呈线性放大。这时候就应该在进入数据集操作前先把外部数据准备好而不是在每一行里去查。把“对每行的计算”尽量限制在纯内存运算是保证脚本性能的基本原则。5.6 快速自查表我把这次涉及的问题整理成一张速查表贴在脚本工程注释里团队协作时能省很多沟通现象大概率原因处理建议filter结果为空/数据变少列名大小写不对或字段类型不匹配打印首行列名核对字段名和类型sortBy不生效排序列名拼写错误或字段值是字符串而非数字先打印列值确认类型后再排序聚合值偏低null被当作0参与运算聚合前统一空值语义过滤或转0join后行数增多右表关联键不唯一先对右表去重再执行join分页数据重复/遗漏分页前未排序或limit写在filter之前固定排序键先filter后limit/page脚本超时/内存溢出全量拉取内存排序join尽量下沉SQL大表走汇总中间表类型报错金额存成字符串参与运算公共清洗函数统一转Number6. 对SMP语言学习的整体体会6.1 从“学语法”到“用语法”的转折点这一篇“数据集操作”之所以在整个基础系列里位置特殊是因为它第一次让脚本从“写给自己看的逻辑”变成“解决业务问题的工具”。前面学变量是学字学循环是学词学函数是学句到数据集操作才真正连成段落能完整表达一段业务语义。我自己带过几个刚接触SMP的同事他们前几篇学得都挺好一到写真实需求就卡住原因几乎都是不会把“这个页面要展示什么数据”“需要哪些中间结果”翻译成数据集操作链。所以我建议在学习这个系列时尽量把每个方法都对应到一个具体业务场景去记filter对应“只要未取消的订单”sortBy对应“按时间从新到旧”groupBy对应“按城市汇总”limit对应“榜单取前十个”。6.2 SMP方法是手段数据思维才是根本我在前面的章节里反复强调“先想清楚再动手”做数据集操作尤其如此。接到一个统计需求先花两分钟在脑子里把数据流画出来原始数据在哪、第一步怎么过滤、第二步怎么分组、第三步要不要排序截断、最终输出什么结构。这个流程想明白写代码就是按图索骥。这里说的“画出来”是指在草稿纸上写清单不是在系统里画流程图。太多人一上来就写脚本写了一半发现分组维度不对又回头改来回折腾。先想清楚数据流比快捷键技巧、语法糖重要一百倍。6.3 最后一个个人小建议我在SMP脚本里所有数据集操作都会默认加一行开头注释写明这段脚本处理的是哪张业务表、最终输出给哪个页面。因为这种脚本的生命周期往往比写它的人在公司的时间还长。一段时间后原开发走了接手的人翻开脚本看到注释和清晰的操作链几十秒就能明白逻辑。如果当时图快没写注释后来人就得靠打印和数据比对反推代价远大于当初多花的一分钟。数据集操作这部分内容本身不复杂但它是业务逻辑从“看得见”到“算得出”的必经之路。下一次做报表、做分析页、做数据看板时不妨先把SQL放下试着用这些方法把数据流搭出来你会发现逻辑清晰了很多脚本也好维护得多。