ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

苍穹插件开发加载数据全攻略:API选型与避坑实战

苍穹插件开发加载数据全攻略:API选型与避坑实战 拿到金蝶云苍穹插件开发的第一个需求十有八九离不开“加载数据”这四个字。比如销售订单上选了客户想自动带出这个客户最近一次成交的单价采购申请审核通过后想回写供应商的信用额度列表页面上想加一个自定义筛选按钮按条件重新拉取单据。第一次接触苍穹二开的人往往以为插件就是挂一个 Java 类、配一个事件剩下的事平台都帮你干了。真正动手才发现光是“把数据查出来”就能走出好几条完全不同的路线选错了路轻则列表卡上几秒重则明明数据库里有记录界面上却什么都查不出来。这篇文章就围绕“加载数据”这个高频场景把我自己在苍穹插件开发里的完整思路写下来。适合刚接触苍穹插件开发的同学参考也适合那些已经在表单插件、列表插件里写过几段逻辑、但还没系统性梳理过取数方式的人。我会按“什么时候加载数据、用什么 API 加载、怎么写最稳、遇到问题怎么排查”的顺序展开。代码基于常见版本的苍穹 Java 插件模型细节 API 以你手上的版本为准。1. 插件到底在哪个环节处理“加载数据”1.1 插件的本质在标准流程上开的口子很多第一次写苍穹插件的人容易把插件理解成“一段独立跑的程序”。实际上完全不是这么回事。苍穹的插件不是独立运行的程序它挂在业务对象的生命周期上。表单、列表、单据操作、服务流程在苍穹内部都被实现成一个个业务对象每个对象在创建、加载、绑定、保存、操作前和操作后这些节点上都会向外部暴露钩子方法。插件要做的事情就是找到对的钩子把代码放进去。可以把它想象成把一个临时员工安排到流水线上。他不是老板不能决定流水线什么时候启动但可以在流水线的固定工序位上插入自己的动作。你重写了哪个方法就相当于把自己安插到了哪个工序位上。这也解释了为什么很多新手会觉得“插件不生效”——不是代码错了而是重写的方法根本没有在预期的时机被调用。拿“加载数据”来说同样的查询代码写在afterBindData里、写在按钮点击里、写在setFilter里执行时机和效果完全不一样。写插件的第一步不是急着写查询而是确认你到底要挂哪个生命周期节点。1.2 表单、列表、服务插件三个最常取数的入口在苍穹里做二开接触最多的就是三类插件表单插件、列表插件、服务插件。它们加载数据的时机和用途有明确分工。插件类型基类典型时机加载数据干什么表单插件AbstractFormPluginafterBindData、click、字段值变更表单显示后自动带入关联数据或用户点按钮时触发查询回填列表插件AbstractListPluginsetFilter、afterLoadData列表查询前追加过滤条件或查询后修正/补充返回数据服务插件AbstractServicePlugin 或实现 IActionServiceexecuteAction在服务端执行一段业务逻辑通常是批处理、操作链扩展、定时任务这些时机归纳起来其实就是两类“界面要数据”和“业务要数据”。界面要数据典型场景是一个页面打开时需要显示另外一张单据的信息或者列表页需要根据入口参数自动筛选。这时候关注的是参数传递、刷新方式、页面状态。业务要数据典型场景是审核操作、保存操作、计划任务里需要查询一批数据做判断或计算用户根本看不到中间的查询过程。这时候关注的是查询 API 选型、空结果处理、性能和事务。多数新手栽跟头就是把“业务要数据”的逻辑写成了“界面要数据”的写法比如在表单插件里弹个列表让用户去选结果后台任务根本没法这样交互。1.3 一个判断准则先确认你要的是界面数据还是业务数据我自己写插件时有个习惯动手前先问一句话“这批数据是给人看的还是给业务逻辑用的”如果答案是“给人看的”那就要考虑数据的展示通道比如ListShowParameter、FormShowParameter这种页面参数对象核心思路是“把参数带过去让目标页面自己查”。如果答案是“给业务逻辑用的”那就要忘掉界面直接走服务端查询 API核心思路是“查得准、查得快、处理全”。后面的两个实战案例正好分别对应这两种思路。第一个案例是表单插件里查单张单据并回填属于业务取数第二个案例是列表插件按条件重新加载属于界面取数。把这两个场景吃透苍穹插件加载数据的骨架基本就搭起来了。2. 查数据的三条路线QueryService、BillQueryService、DBUtils 怎么选2.1 QueryService最通用的数据查询入口如果你只想记住一个取数 API那就是QueryService。它是苍穹后端最通用的数据查询入口适合列表查询、分页查询、按条件过滤查询。它面向的是“业务对象”的数据模型你不需要知道底层物理表长什么样只要知道业务对象的表单标识和字段标识就行。一个典型的查询代码是这样// 构造查询服务 QueryService queryService new QueryService(); // 指定要查询的业务对象这里是销售订单 queryService.setFormId(sal_saleorder); // 只查询需要的字段不要一把梭查全字段 queryService.setSelectFields(id,billno,customer,totalamount); // 构造过滤条件 QueryFilter filter new QueryFilter(); filter.eq(auditstatus, A); // 已审核 filter.ge(totalamount, new BigDecimal(100)); // 金额大于等于100 queryService.setQueryFilter(filter); // 分页 queryService.setPageIndex(1); queryService.setPageSize(50); // 执行查询 QueryResult result queryService.executeQuery(); if (result ! null result.getData() ! null) { for (DynamicObject row : result.getData()) { String billNo row.getString(billno); // 逐行处理 } }这里有两个容易踩的细节。第一setSelectFields里的字段标识不是界面上看到的中文名称而是设计器里的英文字段标识写错了直接报“字段不存在”。第二过滤条件里的参数类型必须和字段类型匹配比如金额字段用BigDecimal字符串字段用String类型不匹配时查询结果会为空甚至报 SQL 类型错误。2.2 BillQueryService面向单据模型查询单据头与单据体BillQueryService和QueryService的区别一句话概括QueryService是通用查询适合列表和批量场景BillQueryService更贴近单据模型适合在表单插件里按单号、按条件查一张或一组单据。BillQueryService bqs new BillQueryService(); bqs.setBillFormId(sal_saleorder); bqs.addSelectField(billno); bqs.addSelectField(customer); bqs.addSelectField(totalamount); bqs.addFilterString(billno ?); bqs.getQueryParameter().put(billno, billNo); DynamicObject[] rows bqs.queryBillRunTime(); if (rows ! null rows.length 0) { DynamicObject order rows[0]; String billNo order.getString(billno); BigDecimal amount order.getBigDecimal(totalamount); }从使用体感来说BillQueryService在“按单号精确查单张单据”这种场景下更顺手返回的是DynamicObject数组直接取第一条就行。它支持查询单据头和单据体的数据并且会带出一些运行时状态。要注意的是不同版本里这个方法名可能不同有的版本是queryBillRunTime有的版本是executeQuery写代码前先看一眼依赖 jar 包里实际的方法签名。2.3 DBUtils/JDBC能不用就不用还有一条路线是直接用DBUtils或者 JDBC 连数据库查。我见过不少从传统 Java 开发转过来的同事一上来就写SELECT * FROM T_SAL_SALEORDER WHERE ...理由是“这样我熟”。但这在苍穹插件开发里是下下策。直接用 JDBC 查物理表有几个致命问题。第一绕过苍穹的缓存、数据权限、字段翻译、组织隔离同样的 SQL 在不同用户、不同组织下结果完全一样这在企业应用里是很危险的。第二物理表的表名和字段名是数据库命名规范跟业务对象的标识完全不同可读性极差。第三苍穹版本升级时物理表结构很可能调整你的 SQL 就报废了。但也不是完全不能用。我自己的经验是只有在这几类场景才考虑 JDBC复杂的聚合统计 SQL、跨库查询、QueryService 确实难以表达的性能敏感查询。用的时候必须自己处理数据权限和组织隔离并且在代码里加清晰注释说明为什么不用标准 API。三条路线用一张表对比一下维度QueryServiceBillQueryServiceDBUtils/JDBC适用场景列表查询、分页、条件过滤按单号查单据、单据体联查复杂统计 SQL、跨库查询返回结构QueryResult / 分页集合DynamicObject 数组ResultSet数据权限自动带出自动带出不带需自己控制缓存支持支持不支持易用程度高高低需维护 SQL我给你的建议很直接90% 的加载数据需求用QueryService就能解决剩下的 9% 用BillQueryService最后那 1% 才轮到 JDBC。别一上来就奔着底层的路去。3. 实战表单插件按单号加载关联单据并回填3.1 需求拆解从“点一下按钮”到“完成回填”现在写一个完整的例子。假设采购订单上有个“关联销售订单号”字段用户点一个“同步数据”按钮系统按这个单号去销售订单里查客户和总金额回填到当前采购订单的客户字段并在备注里追加一行“同步自销售订单 XXX”。这个需求很典型拆解下来有四个动作获取界面上的来源单号按单号查询销售订单数据拿到查询结果写成采购订单的字段值处理查不到、重复单号、重复点击这些边界情况插件类型是表单插件挂在采购订单表单上。触发时机是按钮点击不是afterBindData。为什么不是表单打开就自动同步因为用户需要先录入销售订单号单号可能被修改如果每次字段变化都触发查询回填会干扰用户录入。手动按钮触发是最可控的交互方式。3.2 注册按钮事件与点击处理先看插件骨架public class SyncFromSaleOrderPlugin extends AbstractFormPlugin { private static final String SYNC_BTN btnsync; Override public void registerListener(EventObject e) { super.registerListener(e); // 给按钮注册点击事件 this.addClickListeners(SYNC_BTN); } Override public void click(EventObject e) { super.click(e); // 判断点击的控件是不是我们关心的按钮 if (e.getSource() instanceof Control) { Control control (Control) e.getSource(); if (SYNC_BTN.equals(control.getControlId())) { doSync(); } } } private void doSync() { String sourceBillNo (String) this.getModel().getValue(srcbillno); if (sourceBillNo null || sourceBillNo.trim().isEmpty()) { this.getView().showTipNotification(请先填写关联销售订单号); return; } // 查询和回填逻辑见下文 } }这里有一个非常实际的细节addClickListeners传入的标识必须和元数据里按钮的标识完全一致包括大小写。一旦不一致按钮点击后插件根本没有反应而且平台不报任何错误。排查方法就是在click方法第一行打日志看方法有没有被调用。3.3 按单号查询销售订单查询部分建议单独抽一个方法方便复用和测试private DynamicObject[] querySaleOrder(String billNo) { BillQueryService bqs new BillQueryService(); bqs.setBillFormId(sal_saleorder); bqs.addSelectField(billno); bqs.addSelectField(customer); bqs.addSelectField(totalamount); bqs.addFilterString(billno ?); bqs.getQueryParameter().put(billno, billNo); // 防止脏数据导致查询结果过多限制最大返回条数 bqs.setTop(20); return bqs.queryBillRunTime(); }为什么这里选BillQueryService而不是QueryService因为这是典型的“按单号查单据”场景返回DynamicObject数组直接判断长度即可。QueryService返回的是分页结构还得额外处理QueryResult.getData()反而啰嗦。注意setTop(20)这个细节。理论上单号是唯一的但企业系统里难免有脏数据万一存在重复单号限制了条数至少不会把内存打爆。这也是我在实际项目里吃过亏才养成的习惯——所有查询都设置最大返回条数。3.4 回填字段时最容易踩的类型坑查到数据后回填字段这一步坑最多。写出下面的代码private void doSync() { String sourceBillNo (String) this.getModel().getValue(srcbillno); if (sourceBillNo null || sourceBillNo.trim().isEmpty()) { this.getView().showTipNotification(请先填写关联销售订单号); return; } DynamicObject[] saleOrders querySaleOrder(sourceBillNo.trim()); if (saleOrders null || saleOrders.length 0) { this.getView().showTipNotification(未找到销售订单 sourceBillNo); return; } DynamicObject saleOrder saleOrders[0]; // 基础资料字段需要传基础资料标识 String customerId saleOrder.getString(customer); this.getModel().setValue(customer, customerId); // 数值字段直接传 BigDecimal BigDecimal totalAmount saleOrder.getBigDecimal(totalamount); this.getModel().setValue(totalamount, totalAmount); // 备注字段追加同步来源信息 String oldRemark (String) this.getModel().getValue(remark); String newRemark 同步自销售订单 sourceBillNo; if (oldRemark ! null !oldRemark.isEmpty()) { newRemark oldRemark newRemark; } this.getModel().setValue(remark, newRemark); this.getView().showTipNotification(同步完成); }三个类型坑说一下。第一基础资料字段。saleOrder.getString(customer)拿到的是基础资料的标识不是名称。界面上显示的是基础资料的名称但插件里setValue必须传标识传了名称会导致字段显示异常或者保存报错。第二数值字段。totalamount是金额查询出来是BigDecimal回填时直接传BigDecimal。如果你先toString()再传可能会碰到精度问题或者平台类型转换报错。第三日期字段。如果涉及日期回填最好直接传Date或Timestamp对象不要传字符串。字符串格式和平台日期格式不匹配时界面会显示乱掉。3.5 多结果、重复点击和单据已保存的边界处理多结果的情况上面代码直接取了saleOrders[0]这是合理的但最好加一条日志。重复点击的问题更隐蔽。用户双击按钮会触发两次同步虽然结果一样但会弹两次提示体验很差。我的处理方式是加一个状态标志private boolean syncing false; private void doSync() { if (syncing) { return; } syncing true; try { // 原有逻辑 } finally { syncing false; } }还有一个边界如果采购订单已经保存过再次修改来源单号并同步新数据覆盖旧数据是没有问题的。但如果采购订单已经审核了此时字段还允许修改吗这就涉及到单据状态控制一般要在同步前判断当前单据的审核状态已审核单据应该禁止同步并提示用户。这块逻辑不同企业要求不同但你要有这个意识。字段回填不是简单地 setValue还要考虑单据所处的生命周期。4. 实战列表插件按条件动态加载与刷新4.1 场景从客户列表跳转到销售订单列表并自动过滤第二个案例是界面取数的典型。客户列表页上有一个“查看全部订单”按钮点击后跳转到销售订单列表并且列表只显示这个客户的订单。这个需求用ListShowParameter实现。跳转的核心不是查询数据而是把参数传递过去ListShowParameter param new ListShowParameter(); param.setFormId(sal_saleorder); param.setPageSize(50); param.setCustomParam(customerId, customerId); this.getView().showForm(param);setCustomParam可以往参数里塞任意对象目标列表页打开时在列表插件里能原样取出来。这是苍穹页面间传参最常用的方式之一。4.2 在列表插件里取参数并追加过滤条件销售订单列表挂一个列表插件在setFilter阶段读取参数并追加过滤public class SaleOrderListPlugin extends AbstractListPlugin { Override public void setFilter(EventObject e) { super.setFilter(e); // 从跳转参数中获取客户标识 Object customerId this.getView().getFormShowParameter() .getCustomParam(customerId); if (customerId null) { return; } // 在原有过滤条件上追加客户过滤 if (e.getData() instanceof FilterParameter) { FilterParameter fp (FilterParameter) e.getData(); // 不同版本 API 略有差异核心思路是追加过滤条件 fp.getCustomFilter().addFilter(customerid ?, customerId); } } }为什么在setFilter里做而不是查询完成后再过滤数据因为setFilter是列表查询的必经之路平台每次发起查询都会先进这个方法。在这里追加条件能让查询直接落到数据库层效率最高。如果等数据查回来再在内存里过滤列表页几十上百条数据可能还好一旦分页查询每页只查一页数据内存里根本拿不到全部客户的订单过滤就失效了。这个逻辑说白了能下推到数据库的过滤就不要在内存里做。4.3 自定义按钮触发重新加载重置过滤条件列表页还可能有一个“重置”按钮点击后清空客户过滤显示全部订单。实现方式很简单在列表插件的click方法里处理按钮点击清掉自定义参数后刷新列表Override public void click(EventObject e) { super.click(e); if (e.getSource() instanceof Control) { Control control (Control) e.getSource(); if (btnreset.equals(control.getControlId())) { // 清空自定义参数 this.getView().getFormShowParameter().setCustomParam(customerId, null); // 重新加载列表数据 this.getModel().refresh(); } } }refresh()会重新触发查询流程包括重新走一遍setFilter所以清掉参数后刷新列表自然就恢复成全部数据了。这是一个很实用的小技巧不用手动去拼 SQL也不用重新构造列表对象。4.4 刷新时的高频坑页码状态和过滤条件残留列表刷新这步我踩过的坑可以列一个清单。页码未复位。用户翻到第三页后点“重置”刷新后数据变成了全部订单但页码还在第三页如果第三页已经没有数据列表就显示空白。解决办法是刷新前把页码重置到第一页。大部分版本里getModel().refresh()默认会回到第一页但如果你手动调了页码相关的方法就要特别注意。过滤条件残留。这是更隐蔽的坑。用户从客户列表跳转到销售订单列表列表按客户过滤了。用户手动把列表关闭下次直接从销售订单菜单打开列表如果customerId参数没有清掉列表依然会按那个客户过滤看起来就像“列表坏了永远只显示一个客户的数据”。所以跳转过去的时候一定要保证参数生命周期可控或者在列表插件的afterLoadData里判断当前入口决定是否需要清理参数。事件重复触发。在setFilter里追加过滤条件时千万不要顺手调用this.getModel().refresh()。refresh()会再次触发setFilter于是形成“刷新 - setFilter - 刷新”的循环。正确的做法是setFilter只负责组装条件不要在里面触发查询。这些坑都不是 API 不会用而是对“生命周期”和“事件流”理解不够。我建议你自己在环境里打断点把一次列表刷新从click到setFilter到afterLoadData的完整调用链走一遍比看十篇文档都管用。5. 加载数据时四个最容易翻车的边界问题5.1 数据权限和数据隔离查不到数据先别怀疑 SQL在苍穹里通过QueryService和BillQueryService查询数据时默认会带着当前用户的数据权限和组织隔离。也就是说同一个查询管理员可能看到 100 条数据普通用户可能只看到 20 条这不是 SQL 的问题是权限在起作用。很多新手遇到“查不到数据”第一反应是把过滤条件反复改来改去折腾半天才发现是当前用户没有那个组织的数据权限。我的建议是排查问题时把“当前用户是谁、当前组织是什么、有没有数据权限”这三个问题放在最前面。还有一种特殊情况插件运行在没有用户上下文的场景里比如定时任务、消息订阅、第三方接口回调。这时候你用QueryService查数据可能因为上下文为空一条都查不出来。解决方案是在代码里显式设置操作人、操作组织或者使用系统管理员身份上下文具体看你的业务场景。这里特别提醒不要为了图省事在插件里用 JDBC 绕过权限控制。权限控制是企业系统的底线绕过权限写出来的功能一旦上线就是事故。谁也不想自己写的插件成为数据泄露的入口。5.2 只查需要的字段不要一把梭写QueryService的时候setSelectFields不设置的话有些版本会返回业务对象的全字段。一张销售订单单据头几十个字段单据体可能还有几十个字段查出来全部塞到内存里列表不卡才怪。我的习惯是凡是查询必写selectFields只查当前逻辑需要的字段。这既减少了网络传输量也减少了对象初始化的开销。特别是在循环里查询数据的场景字段少一个性能可能差一倍。5.3 缓存倒灌查出来的是旧数据苍穹的查询 API 是带缓存的。同一张单据短时间内被反复查询第二次查询很可能直接命中缓存速度很快但拿到的可能是修改前的旧数据。有一个我实际遇到的场景审核操作里改完单据的金额状态紧接着在同一个服务里用QueryService去查这个单据的最新金额结果拿到的还是旧值。一开始我以为事务没提交后来才发现是查询缓存的问题。解决办法也很简单对实时性要求高的查询要么在查询参数里显式关闭缓存要么先清理对应单据的缓存再查询。不同版本的 API 略有差异你要在自己的环境里找到对应的关闭缓存的方法。5.4 事件循环setValue 引发的死循环表单插件里有个典型场景监听字段“客户”的值改变事件每当客户变化时就查询这个客户的默认业务员回填到“业务员”字段。代码写起来很自然但这里藏着一个大坑。事件流是这样的用户修改客户 - 触发值改变事件 - 插件查询默认业务员 -setValue(owner, 业务员)- 业务员字段值变化 - 如果业务员字段也有值改变监听又触发事件 - 继续查询、继续回填……一旦链路里有环就会形成死循环轻则页面卡死重则后台持续跑查询。我的解决方案有三种按优先级排回填前判断值是否真的变化了如果新旧值相同就不执行setValue。引入一个 boolean 类型的标志位在批量回填期间置为 true回填结束后置回 false事件处理开头判断标志位直接 return。不用字段值改变事件改成手动按钮触发从交互源头杜绝循环。这三个方案里第一个最轻量第二个最通用第三个最保险。实际项目里我一般先用第一个如果逻辑复杂再上第二个。6. 高频报错与调试经验数据加载不上时的排查链路6.1 插件没生效先查这三件事“插件写了但没反应”是新手问得最多的问题通常原因有三个第一插件类没有部署到正确的位置。开发环境里改了代码要重新编译并且部署到苍穹的运行目录不是在 IDE 里点一下 Run 就完事了。第二插件类没有在元数据里配置或者配置的类名和实际类名不一致。第三方法名拼写错误。registerListener写成了registerListennerclick写成了onClick这类错误不会报编译错但方法永远不会被调用。排查方法很简单在重写的方法第一行加一句日志输出比如System.out.println(SyncFromSaleOrderPlugin.click invoked);然后触发对应动作看控制台有没有输出。没有输出说明插件根本没被挂上或者时机不对有输出说明插件挂载成功问题在后面。6.2 “字段不存在”类错误标识符别手打QueryService执行时如果报“未找到标识符为 XXX 的字段”百分之百是字段标识写错了。这个字段标识不是界面上看到的中文名称而是表单设计器里的英文字段标识。比如“客户”在界面是中文在数据模型里可能是customer或customerid要以设计器里为准。我的习惯是从设计器或元数据里直接复制字段标识绝不手打。手打容易打错大小写而字段标识是区分大小写的。这个习惯帮我省了无数次排查时间。6.3 查不到数据与查到错误数据的排查方向现象可能原因排查顺序查不到数据数据权限、过滤条件类型不匹配、组织隔离、状态条件配置错误1. 确认当前用户权限2. 打印过滤参数3. 用相同条件在查询分析器里执行查到数据但不对过滤条件没生效、缓存、表单标识填错、被其他插件改了参数1. 检查 setFilter 是否被覆盖2. 排除缓存3. 确认 formId 正确查不到数据时最快的方式是把过滤条件里的参数用日志打印出来单独在数据库客户端里执行一遍同样的 SQL。能查到说明 SQL 逻辑没问题问题在权限或缓存查不到直接看 SQL 本身。查到错误数据时先怀疑过滤条件是否真的传到数据库了。有一种情况是你在插件里追加了过滤条件但平台本身的查询参数把你的条件覆盖了导致条件“没生效”。这时候可以用afterLoadData里打印返回的第一条数据的单号反推过滤条件是否生效。6.4 调试加载顺序的完整链路最后分享一下我自己排查数据加载问题时的操作顺序照着走基本能定位 80% 的问题本地起调试环境断点打在registerListener确认插件被加载。断点打在事件方法第一行确认事件被触发。把查询入参打印出来包括表单标识、字段列表、过滤条件、参数值。把打印出来的 SQL 条件和参数直接拿到数据库客户端里执行。数据库能查出数据回到代码里检查 API 版本差异数据库查不出数据检查权限、组织、过滤条件。这套链路的核心思想就一句话先确认代码真的走到了再确认参数真的对了最后确认数据真的存在。大多数排查工作其实是在第一步和第二步之间浪费的时间最多。写到这里第一篇基本把“加载数据”的骨架讲完了。说句实在话金蝶云苍穹的插件开发并不难难点全在“时机”和“边界条件”。把事件时机理清楚把查询 API 的特性记牢数据加载这块就算拿下了一半。我个人建议你先别急着往后看把文中的两个实战例子在自己的环境里跑一遍尤其是表单回填和列表过滤这两个场景跑通了再往下学会有一种豁然开朗的感觉。下一篇我打算写插件里的数据写回和事务控制那里面还有一批更隐蔽的坑等着填。
返回列表