写SMP语言基础知识这个系列到今天已经是第七十六篇了。有朋友问我GBISP通用业务信息平台里的SMP软件制作平台到底是什么简单说它是一套面向业务建模和规则编排的语言跑在GBISP的虚拟机里专门用来描述单据流转、字段计算、服务调用这些企业业务逻辑。前七十五篇把变量、运算符、流程控制、数据映射都过了一遍这一篇我把“函数与模块化”作为主线来写。因为我已经在不止一个项目里看到大家把几十个业务规则堆在同一个脚本里改起来非常痛苦。这篇文章会用实际代码和踩坑经验讲讲SMP语言的函数定义、参数传递、作用域、模块封装最后用一个订单状态机的例子串起来。适合正在用GBISP做配置开发的实施顾问、平台开发者和维护人员阅读。1. 为什么SMP语言需要函数与模块化1.1 GBISP平台的业务逻辑痛点GBISP平台承载了大量企业核心流程常见的订单、采购、库存、审批都跑在它上面。业务逻辑不会只写一次同一个需求往往要同时出现在实体规则、界面保存事件、服务编排等多个入口里。如果没有函数抽象最直接的结果就是重复代码满天飞。我见过一个生产项目折扣计算这段逻辑被复制了十几份散落在不同脚本里。后来规则从“满1000打九折”改成“满1000减120”开发人员改了前端脚本忘了改后端服务脚本导致同一个订单在不同入口算出来的金额不一致。这种问题不是靠细心能解决的而是需要从结构上消灭重复。另一个痛点是平台对单个脚本的复杂度很敏感。一个脚本超过两三百行之后排查问题的成本会急剧上升。运营人员偶尔会反馈某个审批节点执行特别慢技术一看原来是一个三百行的脚本里嵌套了五层循环里面还反复加载实体。没有函数拆分的情况下你很难定位是哪一段逻辑拖慢了整体性能。所以GBISP在后来版本里把函数作为一等公民强制引导大家做模块化。我个人的体会是函数化不仅是为了复用更是为了给逻辑一个明确的边界。边界意味着你可以单独测试可以单独修改不会不小心影响其他逻辑。这一点在企业级系统里比代码洁癖重要得多。1.2 从“脚本堆叠”到“函数抽象”的演进早期SMP语言更像一个线性脚本执行器所有逻辑从上到下依次执行。那时候平台文档里还专门强调“脚本中不要使用跳转语句”因为跳转会让人看不懂执行路径。但随着业务复杂化线性脚本根本撑不住真实需求。比如一个订单同步接口需要先做数据校验再转换格式再调用外部ERP最后记录日志。如果全写在一个脚本里任何一个环节调整都要动整段代码代码评审也很尴尬。后来SMP引入了函数允许把一段逻辑封装成一个可调用的单元。这个演进其实和做菜的过程很像一开始你按菜谱一步步操作调料、切菜、炒制全混在一起做多了以后你会先准备好所有食材再按步骤执行。函数就是“备菜”的过程——每道工序独立出来可以复用也可以单独检查。到了这一步业务流程的维护就变得直观了。现在的GBISP开发规范里甚至开始要求一个函数只做一件事。界面上新增一个按钮配置文件里只放事件入口函数真正的业务逻辑全部委托给服务层函数。这看起来多绕了一层但对于半年后的维护来说价值巨大。1.3 SMP语言函数设计与C语言/通用脚本的异同SMP语言的函数语法乍一看很接近C语言声明使用func关键字参数写在括号里返回值用return。不过它去掉了C语言里最危险的指针概念也不用手动管理内存变量生命周期由平台的虚拟机统一管理。这样设计的目的很明确GBISP的使用者里有大量实施顾问他们不是专职程序员不能要求每个人都理解内存地址和指针运算。同时SMP语言的函数又不像JavaScript那样随意。JavaScript里函数可以嵌套、可以作为参数传递、可以闭包访问外部变量灵活性极高但也容易出现隐式依赖。SMP语言刻意砍掉了闭包能力函数体只能访问全局变量、自身参数和内部局部变量。这样每次调用行为可预测排查问题时不需要去猜“这个变量是从哪一层带进来的”。我用一个表格总结一下SMP语言函数和常见语言的差异特性SMP语言C语言JavaScript函数关键字func无关键字function手动内存管理否是否闭包不支持不支持支持默认参数值支持不支持支持函数作为参数有限支持函数指针支持运行时异常处理支持raise返回错误码try/catch这个设计取舍很符合平台定位既要有足够的表达能力又不能复杂到失控。对比之后你会发现SMP语言更像“带类型约束的脚本语言”每位学过一点C或者JavaScript的人都能快速上手。2. 函数的基础语法与执行机制2.1 函数声明和调用格式SMP语言中定义一个函数的格式如下func calcTotalPrice(orderId, discountRate1.0) { var order loadEntity(Order, orderId); var total 0.0; for (var i 0; i order.items.len(); i) { total order.items[i].price * order.items[i].qty; } return total * discountRate; } var result calcTotalPrice(ORD2025001, 0.8);func是关键字后面跟函数名。函数名建议使用动词开头比如calc、save、validate。参数列表里可以给默认值调用时如果不传就使用默认值。这里discountRate1.0意味着不传折扣时按原价计算很有用。返回值用return也可以没有返回值此时函数默认返回null。调用函数时直接写函数名加括号参数按位置匹配。SMP语言没有函数重载的概念同一个模块里不允许出现同名函数否则编译器会直接报错。这里要注意函数声明本身在模块加载时就会注册和声明位置无关。也就是说你可以在文件底部定义函数、在顶部调用这叫“声明提升”。但为了可读性我始终建议把函数定义统一放在脚本后半部分入口逻辑放在最前面。2.2 参数传递的规则与选择SMP语言参数传递遵循一个简单规则标量类型按值传递实体和列表按引用传递。标量包括整数、小数、布尔、字符串函数内修改这些参数不会影响调用方的变量。实体对象和列表则不同函数内修改了某个字段或元素调用方拿到的对象也会被改掉。这个规则初看有点绕但它有现实考量实体对象可能包含几十个字段如果按值传递每次调用都要整体拷贝一遍性能损失很大。列表也类似按值拷贝会导致内存膨胀。所以平台选择了引用传递保证大对象传递高效。代价是容易出现“未预期的修改”。比如你写了一个函数只想读取订单的客户名结果不小心改了客户实体的地址字段这个修改会直接影响后续流程。我的建议是如果函数不改动传入的实体就把参数名设为只读前缀比如readonlyOrder同时在代码注释里标明“本函数不修改该实体”。如果确实需要修改实体最好通过返回值把新状态抛出来不要在参数上“偷偷”动手。对于列表参数同理。需要过滤列表时新生成一个列表返回而不是在原列表上执行remove或clear。这样做虽然在短期内多了几行代码但长期看可以避免大量诡异问题。2.3 返回值与异常处理SMP语言的函数返回值设计得比较克制一个函数只能返回一个值。如果你想同时返回状态和消息有两种常用方案一种是用实体封装比如返回一个带code和message字段的结果对象另一种是返回布尔值具体错误信息通过异常机制抛出。平台推荐的处理方式是使用异常。SMP语言里有raise语句可以在函数内部主动抛出业务异常func validateStock(orderId) { var order loadEntity(Order, orderId); for (var item in order.items) { var product loadEntity(Product, item.productId); if (product.stockQty item.qty) { raise INSUFFICIENT_STOCK; } } return true; }调用方可以用try/catch捕获这个异常try { validateStock(ORD2025001); } catch (ex) { logInfo(库存不足订单号 ex.code); return; }异常的好处是强制调用方处理异常分支不会像返回错误码那样容易被忽略。我的习惯是正常流程用返回值表达异常流程用raise表达两者不要混用。很多事故都发生在函数返回false调用方却忘了判断导致流程继续往下走。用异常机制之后漏判的概率会大大降低。3. 作用域、生命周期与变量管理3.1 局部变量、全局变量与并发安全函数内部用var声明的变量是局部变量只在函数执行期间存在每次调用都会重新创建。局部变量存放在虚拟机栈上执行完就释放不需要你操心。这个设计和大多数编程语言一致是提升函数可预测性的基础。全局变量在模块顶层用global声明整个模块运行期间都可以访问。全局变量适合存放配置项、常量字典、允许状态流转表等只读数据。但全局变量也有一个隐藏问题——并发安全。GBISP平台通常以多线程方式处理请求多个请求可能同时执行同一个函数。如果函数内部对全局变量做写入操作后一个请求就会覆盖前一个请求的数据产生典型的串数据问题。我建议把全局变量默认视为只读。如果非要有缓存用专门的缓存服务而不是全局变量。平台自带的cacheSet和cacheGet函数可以按业务键存储临时数据并且支持超时时间。哪怕只是为了存一段配置解析结果也建议走缓存接口而不是自己搞一个全局Map。3.2 静态变量的缓存效果与集群隐患SMP语言还有一个特殊变量类型静态变量。在函数内部用static声明变量会在第一次调用时初始化之后一直保留到虚拟机重启。它可以用作函数级缓存比如保存某个复杂配置的解析结果避免每次都重新解析。func getConfigValue(key) { static configCache {}; if (configCache.containsKey(key)) { return configCache[key]; } var value executeQuery(SELECT value FROM config WHERE key ?, key); configCache[key] value; return value; }这段代码在单实例部署时很高效但有一个大坑GBISP经常是多节点集群部署每个虚拟机都有自己的静态变量节点之间数据不同步。如果你在静态变量里存了某个标记本来期望它是全局唯一结果不同请求被路由到不同节点行为就会不一致。所以静态变量只适合保存“所有节点都一样”的只读缓存比如配置表、枚举字典。如果有跨节点状态请用分布式缓存或数据库。踩过这个坑之后我对静态变量的态度变成了能用global常量解决的问题绝对不上static缓存。3.3 命名规范和坏味道识别函数与变量的命名规范直接影响后续维护成本。GBISP官方推荐的是lowerCamelCase字段名用orderId、productName这种风格。我还会给变量加轻量前缀来标识类型实体对象用entity或直接以业务词开头列表用list布尔用is/has时间用time。举个例子一个函数内部可能会同时出现orderEntity和orderId前者是订单实体后者是订单编号字符串。如果不加区分很容易把字符串当成实体使用。平台虽然会做类型校验但运行时才报错不如一开始就避免歧义。我总结了几种典型坏味道看到就应当重构一是函数超过80行说明职责太多二是参数超过4个建议用一个实体封装三是函数内部被注释掉大量代码说明逻辑不稳定四是同名函数在不同模块里行为不一致容易造成认知混乱。识别这些坏味道之后果断拆分维护成本会直线下降。4. 模块化封装与复用策略4.1 函数库、业务模块怎么划分在GBISP中SMP脚本不是一个个孤立的文件而是按模块组织。一个模块可以包含多个SMP文件每个文件里可以有多个函数。官方建议的目录结构大致是my-app/ lib/ common.smp date-utils.smp modules/ order/ order-service.smp order-rule.smp customer/ customer-service.smp flows/ order-flow.smplib目录放纯函数只处理基础逻辑比如日期格式化、金额舍入、字符串校验。modules目录放业务模块按领域划分。flows目录放流程编排脚本负责调用模块函数。一个清晰的划分原则是函数库不依赖任何业务实体只做通用计算业务模块可以依赖函数库也可以依赖其他业务模块流程编排只负责串联不写具体业务规则。这样分层以后你需要修改“订单金额计算”时只需要动order-service.smp不需要去翻流程文件。遇到一个函数该放哪里的问题我的判断标准是如果这个函数在两个及以上业务模块中复用就把下沉到lib如果只在当前模块使用就留在本模块内部。这个规则简单但很有效。4.2 跨模块调用与避免循环依赖模块之间通过import语句引入。比如订单模块需要读取客户信用额度可以这样写import modules/customer/customer-service.smp; func checkCustomerCredit(orderId) { var order loadEntity(Order, orderId); var credit customer_service.getCreditLimit(order.customerId); if (order.totalAmount credit) { raise CREDIT_LIMIT_EXCEEDED; } }这里customer_service.getCreditLimit是调用客户模块里导出的函数。SMP语言要求只有用pub func声明的函数才能被其他模块调用未加pub的函数等同于模块私有。这个设计很关键它明确了模块接口防止别人依赖你的内部实现。跨模块调用最大的坑是循环依赖。比如订单模块调用客户模块客户模块又调用订单模块编译器虽然能检测出部分环但复杂环往往在运行时才暴露表现为模块加载顺序异常。我的习惯是划分好依赖方向底层lib不依赖业务模块客户模块可以在订单模块之前业务模块之间如果确实需要互相调用就把公共逻辑提取到公共模块而不是直接互相引用。4.3 版本化发布与回滚机制GBISP对SMP模块提供打包和版本管理能力。每次修改完代码可以打包成一个.gsmp包包内包含模块文件、依赖声明和版本号。版本号建议遵循语义化主版本号、次版本号、修订号比如1.3.2。主版本号不兼容变更时增加次版本号增加功能时更新修订号只修复缺陷。发布流程上我强烈建议走“开发环境打包 → 测试环境验证 → 生产环境发布”的链路。不要图省事直接在测试环境改完就同步到生产。在测试环境验证时要专门测试模块升级后老流程是否还能正常运行特别是接口签名有没有变、默认参数有没有调整。回滚是必须提前准备的。生产环境上线的同时保留上一版本的.gsmp包。一旦发现新版本有严重问题直接导入旧包并重启相关虚拟机即可。这里的难点是数据兼容如果新版本函数已经写入了新结构的数据旧版本可能读不了。所以我都会在发布前检查函数是否对已有数据做了迁移避免回滚后数据错乱。5. 实战用SMP语言实现订单状态机5.1 需求描述与函数拆分讲完基础我们用订单状态机来串一串。需求是这样的订单有五个状态分别是待支付、已支付、已发货、已完成、已取消。允许的流转包括待支付到已支付、待支付到已取消、已支付到已发货、已支付到已取消、已发货到已完成。每次状态变更都需要校验是否允许并且记录下来是谁在什么时间操作的。直接点说就是要实现一组状态流转函数并强制所有入口都走这些函数不要直接修改订单的state字段。按照这个需求我拆分出六个函数payOrder、shipOrder、completeOrder、cancelOrder以及两个基础函数validateState和logChange。这样拆分以后每个函数的职责单一新增一种状态流只需要改一处配置。在设计状态表时我用一个全局常量保存允许的流转关系。这样做的好处是状态规则集中可见新增状态时一目了然。5.2 核心代码与逐段讲解下面是一段可直接在SMP开发台中运行的示例代码global allowedTransitions { PENDING_PAYMENT: [PAID, CANCELLED], PAID: [SHIPPED, CANCELLED], SHIPPED: [COMPLETED], COMPLETED: [], CANCELLED: [] }; func validateState(currentState, targetState) { var allowed allowedTransitions.get(currentState, []); if (!allowed.contains(targetState)) { raise STATE_TRANSITION_NOT_ALLOWED; } } func logChange(orderId, fromState, toState, operator) { var log createEntity(OrderStateLog); log.orderId orderId; log.fromState fromState; log.toState toState; log.operator operator; log.createdTime now(); saveEntity(log); } func payOrder(orderId, operator) { var order loadEntity(Order, orderId); if (order null) { raise ORDER_NOT_FOUND; } validateState(order.state, PAID); var oldState order.state; order.state PAID; order.payTime now(); saveEntity(order); logChange(orderId, oldState, order.state, operator); }第一段是全局状态转移表用Map类型定义。为什么用全局常量而不是在每个函数里写if判断因为状态规则是业务配置集中起来更容易维护。validateState函数先取出当前状态允许到达的目标集合如果不包含目标状态就抛出异常。logChange函数创建一个新的日志实体保存订单号、变更前状态、变更后状态、操作人、操作时间。为什么要单独拆出来因为每个对外函数都要记录日志不拆开就得重复写五遍。payOrder函数里先做了空值判断然后调validateState再更新订单状态和支付时间最后保存并记录日志。这里注意必须在保存订单之后再记录日志避免日志存在但订单没保存成功的情况。如果saveEntity失败下面的logChange就不会执行这个顺序是刻意的。完整代码还包括shipOrder、completeOrder、cancelOrder它们结构相似区别在于目标状态和附带字段。你可以按照payOrder的模式补齐。5.3 测试、联调与性能观察代码写完后不要急着发布。在SMP开发台里可以单步调试函数我在调试时通常会在validateState的入口打断点查看传入的currentState和targetState是否符合预期。调试面板里可以直接看到全局变量allowedTransitions的内容确认状态配置没有拼写错误。单元测试方面SMP语言提供简单的断言函数比如assertEqual。我建议至少覆盖这些场景合法流转成功、非法流转抛出异常、订单不存在抛出异常、日志记录数量是否增加。特别是非法流转比如从已完成直接取消必须确认函数会拦截。联调时还要模拟并发场景。两个操作员同时操作同一订单一个点支付一个点取消。因为两个函数都会先加载订单、再保存订单后保存的会覆盖先保存的。要解决这个问题给订单实体加一个version字段保存时判断版本号是否冲突冲突就重试。这个操作看起来不起眼但能避免很多生产事故。性能方面状态机函数本身很轻主要代价是loadEntity和saveEntity两次数据库交互。如果订单状态频繁流转可以考虑把日志写入改成异步队列但初始版本先用同步保存简单可靠。监控函数执行时间时一旦发现超过300毫秒就要检查是不是数据库慢查询或者实体字段过多。6. 常见问题与排查技巧实录6.1 函数定义相关的编译错误先说说最常见的编译错误。你可能会遇到func not found也就是函数找不到。这个错误八成是import路径写错了或者被调用的函数没有用pub func声明。SMP语言的模块依赖是静态解析的路径写错只有在打包或运行启动时才会暴露。我的排查步骤是先看文件头部的import语句再用开发台的“跳转到定义”功能确认函数真实位置。另一个常见错误是duplicate func函数重复定义。有时候是同一个模块里写了两个同名函数有时候是间接引入了两个不同模块恰好导出了同名函数。遇到后者我建议在导入时使用别名比如import modules/customer/customer-service.smp as customer;然后通过customer.getCreditLimit调用避免命名冲突。6.2 类型不匹配与运行时错误运行时报TypeError: expected number, got string这类错误最常见原因是外部传入的参数是字符串但函数内部按数值计算。比如调用微信支付回调时支付金额通过JSON传过来解析后可能是字符串199.00直接参与乘法运算就会报错。解决办法是在函数入口处做显式转换var amount toNumber(payload.amount); if (amount null) { raise AMOUNT_FORMAT_ERROR; }另一个参数顺序错误也让人头大。比如calcTotalPrice(orderId, discountRate)写成了calcTotalPrice(discountRate, orderId)运行时不一定会立刻报错但结果完全不对。排查时用调试面板查看函数入参的实际值对比声明顺序很快就能定位。为了避免这种问题我会在函数内部第一行加一段参数打印日志联调时打开上线前关闭。6.3 执行效率与递归深度限制SMP语言对递归深度有默认限制通常在64层。业务上最常见的递归是组织架构树、菜单树这类层级数据。如果深度超过限制函数会直接抛异常。我建议把递归改成显式栈迭代用一个列表暂存待处理节点。这样做虽然代码看起来复杂一点但不会有栈溢出风险。循环方面单个函数默认循环上限是10万次。大多数业务不会达到这个上限但如果你在一个循环里反复执行loadEntity循环100次就是100次数据库查询性能同样堪忧。正确做法是批量查询比如用loadEntities一次加载多个实体。我在优化一个报表脚本时把循环里的单条查询改成批量查询后执行时间从9秒降到了0.4秒。6.4 我用着最顺手的几个习惯最后分享几个实际操作中沉淀下来的习惯算不上惊天动地但确实能省很多事。第一所有纯计算函数都放到lib/common.smp里并且写单元测试。这类函数不依赖实体、不依赖外部服务测试成本最低。把复杂的价格计算、分摊逻辑放进去基本能保证核心业务正确。第二默认参数优先于函数重载。SMP语言不支持重载但可以通过默认参数模拟出类似效果比如calcTotalPrice(orderId, discountRate1.0)。这样调用方根据场景选择传一个参数还是两个参数语义清晰。第三函数体尽量控制在50行以内。一旦超过就检查是否能把其中一段逻辑抽成私有函数。我在审查代码时50行是一个心理阈值超过之后可读性明显下降。第四状态码、枚举、状态流转表放在模块顶部的全局常量里不要散落在函数内部。配置集中后业务方提出调整只需要改一处不用挨个函数翻。第五每个对外函数写一行变更注释格式是“日期 修改人 修改原因”。平台没有强制要求但半年后回来看代码时这些注释能帮你快速回忆当初为什么要这么做。这套规矩看起来很笨但长期维护下来你会感谢当时愿意多写几行注释的自己。如果你也正在用SMP语言做业务编排不妨先把文中这个订单状态机在开发台里跑一遍然后试着把自己的核心业务函数按模块拆分。下一篇我准备聊错误码与日志追踪到时候可以在同一个状态机上继续扩展。