简介:面向K/3 Cloud BOS插件开发初学者的一份入门文档,围绕BOS平台动态扩展、插件分类与开发环境搭建展开,适合需要理解套打、万能报表及业务对象部署等BOS产品应用场景的开发者查漏补缺。压缩包内共1个docx文件,约1.9MB,篇幅集中,便于按章节通读和检索。资料从团队服务器、数据库、Web服务器与客户端配置要求讲起,梳理动态表单插件、服务插件、校验规则插件和表单构建插件四类插件的职责,并给出BOS架构模型、Visual Studio 2012开发工具、数据库字符集与排序规则、插件编译路径及注册配置等要点。文中还以Hello World为例演示插件类定义、方法重载与运行测试流程,可帮助读者快速建立开发环境并完成首个插件实践。已有216人学习。
1. 一段 C# 编译成 dll,丢进 Bin 目录,界面就变了
把一段 C# 编译成 dll,丢进K3CloudServer\Bin目录,再在设计器的单据属性里把插件勾上,重新打开单据,一个 Hello World 弹窗就出来了——没有重启服务,没有重新部署站点,也没有动产品一行代码。这大概是「K/3 Cloud BOS 插件开发基础」里最容易被当成操作步骤带过的一环,但它其实是整套 BOS 产品应用汇总的地基:插件不是被编译进产品的扩展,而是产品在运行时按元数据里登记的类型名,去 Bin 目录反射实例化再回调。想明白这一条,后面那些「插件为什么没生效」「值为什么不进状态管理器」「删掉已注册插件为什么有风险」的问题,才有统一的答案。
2. 元数据双螺旋:View 和 Model 为什么必须拆成两半
BOS 平台的核心是动态领域模型建模:把业务拆成各种标准接口和标准服务,再用插件去扩展标准业务。要理解插件能干什么,得先看清它挂在哪两根轴上。
动态表单模型同时包含表单外观和表单业务逻辑。外观管理界面控件外观及样式,由视图(View)控制;业务逻辑包括服务、校验器、操作和业务规则,由模型(Model)控制。两者都在 IDE 里配置,配置结果落进动态表单元数据——具体是LayoutInfo记录外观数据,BusinessInfo记录逻辑数据,两个类分别由View和Model持有。
这条链路在运行时是这样走的:客户端先向服务端发起 HTTP 请求,服务端由控制器服务接住并转送给动态表单模型控制器,控制器再去访问动态表单视图;视图加载外观模型,并从动态表单模型取数据模型。注意这里的方向——视图不是数据的生产者,它只是外观的持有者,数据在 Model 那边。
2.1 一次表单请求里,LayoutInfo 和 BusinessInfo 各管什么
把两个元数据对象的分工说透,插件该往哪写就有依据了。
LayoutInfo管的是「长什么样」:控件在哪个页签、是否可见、是否锁定、菜单项是否可点、字段标题文字、控件样式。插件里所有View.GetMainBarItem("tbNew").Visible = false、View.StyleManager.SetVisible("FTab_Field", null, false)、View.SetFormTitle(...)这类调用,动的都是LayoutInfo这一侧。
BusinessInfo管的是「是什么、怎么算」:有哪些实体(Entity)、每个实体有哪些字段(Field)、字段的类型和动态属性、单据体结构、操作定义、校验规则挂载点。插件里View.BusinessInfo.GetEntity("FEntity")、BusinessInfo.GetField("FQty")、View.ParentFormView.BillBusinessInfo.GetForm().Id这些调用,走的是BusinessInfo这一侧。
同一个 BusinessInfo 可以被多份 LayoutInfo 复用——这就是「多视图模型接口」存在的原因。同一张单据在 PC 端布局和另一个业务场景布局下,字段定义是同一套,摆法可以不同。插件如果能直接访问BusinessInfo,就意味着它拿到的字段 Key 在所有视图下都成立,这是插件代码可移植的前提。我一般会这么理解这个设计:LayoutInfo 是「可替换的表现」,BusinessInfo 是「不可替换的契约」。
2.2 内部服务接口为什么不对插件开放
视图对外提供两个接口:IDynamicFormView和IDynamicFormViewService。前者是视图接口,包含领域模型元数据、多视图模型接口、操作转发指令和通用属性方法,插件可以直接访问;后者是动态表单内部使用的接口定义,包含 Controller 消息路由方法,插件开发不需要关注。
模型侧是同样的套路:IDynamicFormModel和IDynamicFormModelService,前者含领域模型元数据和数据操作方法,插件可直接访问;后者是内部接口。
这个「一个对外、一个对内」的切法值得多说两句。IDynamicFormViewService里的消息路由方法,是控制器和视图之间的私有协议——它的参数形态、调用时序都跟框架内部实现绑死。如果开放给插件,等于把框架的私有协议变成公开 API,任何一个版本升级都可能踩到第三方代码。所以框架把「你能调的」收敛成IDynamicFormView/IDynamicFormModel,把「我怎么调的」藏在 Service 接口后面。
注意:插件里只应该依赖
IDynamicFormView和IDynamicFormModel这两个接口。哪怕调试时看到某个对象上挂着 Service 接口的方法,也别去调,那是给自己埋升级雷。
IDynamicFormView提供的能力大致分三类:一是访问元数据(BusinessInfo、LayoutInfo);二是改界面(访问菜单、修改控件样式、设置标题、更新界面、刷新);三是驱动流程(调用表单服务、执行操作、发送客户端指令、打开表单、动态注册插件)。IDynamicFormModel提供的则集中在对数据操作:初始化、新增表单数据、复制数据、删除数据、定位当前分录数据行、设置值。
2.3 DynamicObject:为什么数据层不给你强类型实体
数据承载这一块,平台默认用DynamicObject。IDynamicFormModel的DataObject属性就是当前表单的数据对象,它里面装着单据头数据,单据体则是集合对象DynamicObjectCollection,而且可以有多个。
这意味着你拿不到PurchaseReceipt这种强类型实体,只能按字段 Key 去取值赋值。原因不难推:字段是 IDE 里配出来的,元数据可变,编译期根本不知道有哪些字段,强类型实体无从生成。用DynamicObject把「结构」推迟到运行时,才能支撑「不改代码就加字段」这件事。
平台留了一个口子:DynamicObjectType.ClrType属性可以指定自定义类。但要求指定的类型必须派生自DynamicObject——你可以在自己的类上加属性、加方法,但底层的键值存取语义不能变,否则元数据绑定就断了。
看一段按元数据取单据体数据的写法:
// 当前表单的数据包,包含单据头和所有单据体 DynamicObject bill = this.Model.DataObject; // 从业务元数据里拿到单据体定义,而不是硬编码字符串猜结构 Entity entity = this.View.BusinessInfo.GetEntity("FEntity"); // 单据体是集合对象,一个单据可以挂多个 DynamicObjectCollection rows = (DynamicObjectCollection)entity.DynamicProperty.GetValue(bill); foreach (DynamicObject row in rows) { // 按字段 Key 取值,Key 来自 BusinessInfo,不是显示名 Field qtyField = this.View.BusinessInfo.GetField("FQty"); object qty = qtyField.DynamicProperty.GetValue(row); }这里的关键不是语法,而是「取值必须经过 Field 的 DynamicProperty」这个约定。字段的存储位置、类型转换、空值语义都封在DynamicProperty里,你绕过它直接按字符串索引取值,在简单字段上可能也能跑,但遇到基础资料字段、多选字段、带组织的字段就会翻车。血泪经验是:只要能从BusinessInfo拿到Field,就别偷懒直接写row["FQty"]。
3. 继承链与事件边界:BarItemClick 到底是谁在调用
搞清楚了 View/Model 的分工,下一个问题是插件本身怎么被组织。BOS 平台按用途给了四类插件:动态表单插件、服务插件、校验规则插件、表单构建插件。
动态表单插件是业务逻辑封装的插件,用于单据上各种事件、操作和服务的介入,提供了一系列事件、操作和服务接口,也是最常用的一类。服务插件基于动态表单服务端,提供服务编程接口,这类接口在外部服务里被直接调用。校验规则插件针对业务规则做校验。表单构建插件对界面加载控件和元数据动手,通过它可以对界面控件及元数据做动态调整。
3.1 从 AbstractDynamicFormPlugIn 到 AbstractBillPlugIn 的继承链
动态表单模型是通过插件代理实现业务逻辑的,对外的接口主要就是插件。命名空间集中在BOS.Core.DynamicForm.PlugIn,里面几个关键类型:
| 类型 | 定位 |
|---|---|
AbstractDynamicFormPlugIn | 动态表单插件抽象基类 |
AbstractDynamicFormDataBinder | 动态表单数据绑定器抽象类 |
AbstractDynamicWebFormBuilderPlugIn | 动态表单页面元数据构建插件 |
AbstractOperationServicePlugIn | 操作服务插件抽象类 |
业务模型类(插件、服务)都继承自抽象表单插件。四条主干是:
- 单据插件:
BOS.Core.Bill.PlugIn.AbstractBillPlugIn - 基础资料插件:
BOS.Core.Base.PlugIn.AbstractBasePlugIn - 动态表单插件:
BOS.Core.DynamicForm.PlugIn.AbstractDynamicFormPlugIn - 列表插件:
BOS.Core.List.PlugIn.AbstractListPlugIn
单据插件的继承链是System.Object → AbstractDynamicFormPlugIn → AbstractBillPlugIn → AbstractBasePlugIn。这条链不是随便排的:越往上越通用,越往下越贴近具体单据形态。AbstractBillPlugIn之所以在AbstractBasePlugIn之上,是因为业务单据比基础资料多了单据体、操作、工作流这些概念,基类要先把这些能力备齐。
写一个最小的单据插件长这样:
using BOS.Core.Bill.PlugIn; using BOS.Core.DynamicForm; using BOS.Core.DynamicForm.PlugIn.Args; namespace MyDev.SCM.Stock.Business.PlugIn { // 继承单据插件基类,等于同时订阅了 View 层和 Model 层的事件 public class ReceiptEdit : AbstractBillPlugIn { public override void BarItemClick(BarItemClickEventArgs e) { base.BarItemClick(e); // 先走基类,保留产品原有处理 if (e.BarItemKey == "HelloWorld") // 按菜单 Key 精确匹配 { this.View.ShowMessage("Hello world!", MessageBoxType.Notice); } } } }e.BarItemKey是菜单项在元数据里的唯一标识,不是界面上看到的显示名。把base.BarItemClick(e)放在最前面,是为了让产品原有的点击逻辑先跑完,你的扩展只做增量——顺序反过来,某些依赖基类初始化的场景就会拿到空引用。
3.2 View 事件和 Model 事件的分界线在哪
插件能订阅的事件分两套接口。
IDynamicFormViewPlugIn是 View 层插件接口,实现它的插件可以接收动态表单 View 层事件。清单很长,按语义归类更好记:
| 类别 | 代表事件 |
|---|---|
| 初始化与生命周期 | OnInitialize、BeforeClosed |
| 数据绑定 | BeforeBindData、AfterBindData |
| 菜单与按钮 | BarItemClick、AfterBarItemClick、ToolBarItemClick、EntryBarItemClick、ButtonClick、AfterButtonClick |
| 分录交互 | EntityRowClick、EntityRowDoubleClick、AfterCopyRow、FireEntryCheck、EntryButtonCellClick |
| 基础资料 | BeforeF7Select、AfterF7Select |
| 操作 | BeforeDoOperation、AfterDoOperation |
| 其他控件 | FieldLabelClick、ListViewClick、TabItemSelectedChange、TreeNodeClick、TreeNodeDoubleClick、TreeDragDrop |
IDynamicFormModelPlugIn是 Model 层插件控制接口,事件少但都在要害上:
| 事件 | 触发时机 |
|---|---|
CreateNewData | 创建新业务对象,插件可自行构造数据包 |
AfterCreateNewData | 业务对象创建完成后扩展 |
AfterCreateNewEntryRow | 新增、插入、多行输入后调用 |
BeforeUpdateValue | 值改变、更新之前 |
DataChanged | 字段值改变后 |
分界线其实很清晰:View 层事件处理「用户做了什么」(点了哪个菜单、选了哪个基础资料、关页面),Model 层事件处理「数据怎么变」(新对象怎么建、新分录行怎么补默认值、值改之前要不要拦)。判断一个需求写在哪一侧,问一句「这件事在没有人操作的时候还会发生吗」——会,就是 Model 层;不会,就是 View 层。
3.3 服务插件、校验规则插件、表单构建插件各自解决什么
这四类插件经常被混着用,实际边界按「谁在什么时候调它」划分。
动态表单插件是界面驱动的,跟着表单走,用户打开单据就加载。服务插件则不同,它是基于动态表单服务端的插件,提供服务编程接口,由外部服务直接调用——也就是说它可以脱离界面被调起,适合做「一批数据按规则生成另一批数据」这种后台逻辑。校验规则插件专注在业务规则校验上,它不做业务动作,只回答「这条数据合不合法」。表单构建插件管的是界面加载时的控件和元数据,可以在界面构建阶段动态调整控件和元数据,实现特殊需求——它跑在界面成型之前,所以能改的是「界面长什么样」这件事本身,比AfterBindData里改样式更早一步。
一个经验判断:能靠 IDE 配置(实体服务规则、字段属性)做完的,别写插件;必须在数据变更瞬间拦截的,写 Model 层插件;必须在界面成型前改结构的,写表单构建插件;脱离界面也要跑的,写服务插件。
4. 加载时序:七个回调为什么必须是这个顺序
最简单的 Hello World 写过之后,真正让人困惑的往往不是语法,而是「我这段代码到底什么时候跑的」。动态表单元模型包含外观模型和表单逻辑模型,第一次访问时会先加载元数据,初始化视图和模型对象,初始化页面,然后创建数据包并绑定数据。整套加载过程中插件回调的顺序是:
OnInitialize→CreateNewData→AfterCreateNewEntryRow→AfterCreateNewData→BeforeBindData→AfterBindData→BeforeClosed
另外还有AfterCreateModelData,在模型层数据包创建完毕时触发,只在新增表单模型后触发,用于对新增后的表单模型做相关操作,这个插件的操作不会引起Model.DataChanged值改变。
4.1 时序全表与每一步的可写窗口
把每一步「能改什么、改了算不算数」列清楚,比记顺序更有用:
| 阶段 | 典型用途 | 赋值是否进状态管理器 |
|---|---|---|
OnInitialize | 取 Global 参数、动态初始化控件数据源、设置标题 | 不涉及数据包 |
CreateNewData | 自己构造数据对象替掉默认空包 | 否,整包下发 |
AfterCreateNewEntryRow | 新增分录行后补默认值、做行内判断 | 是 |
AfterCreateNewData | 新建后按参数动态赋缺省值 | 否,整包下发 |
BeforeBindData | 绑数据前增删分录行、预置结构 | 否,不被记录 |
AfterBindData | 控制可见性、菜单启停、样式 | 是 |
BeforeClosed | 关页面前收尾 | — |
OnInitialize是最早能拿到 View 的地方,但它只负责动态表单实例初始化,包括单据 Global 参数(有些参数仅仅在使用时候才获取)、动态初始化控件数据源。典型场景是批量修改界面:初始化时把允许修改的字段塞进下拉列表。这里要遍历元数据里所有字段,用Field.FUNCONTROL_BULK_EDIT位判断是否支持批量修改,再用LayoutInfo.GetAppearance(field.Key)看该字段在编辑态是否可见,两个条件都满足才加进候选。
public override void OnInitialize(InitializeEventArgs e) { // 先按 formId 重新加载一次元数据,拿到权威的字段定义 FormMetadata metadata = (FormMetadata)ServiceHelper.MetaDataServiceHelper .Load(this.View.Context, this.View.ParentFormView.BillBusinessInfo.GetForm().Id); List<EnumItem> list = new List<EnumItem>(); foreach (Field field in metadata.BusinessInfo.GetFieldList()) { // 条件一:元数据里声明了支持批量修改 if ((field.FunControl & Field.FUNCONTROL_BULK_EDIT) != Field.FUNCONTROL_BULK_EDIT) continue; // 条件二:该字段在编辑态可见,隐藏字段不予显示 Appearance app = metadata.GetLayoutInfo().GetAppearance(field.Key); if (app != null && (app.Visible & Appearance.VIS_EDIT) != Appearance.VIS_EDIT) continue; list.Add(new EnumItem { Value = field.Key, Caption = field.Name }); } // 按当前语言排序,保证下拉顺序稳定 list = list.OrderBy(p => p.Caption[this.View.Context.UserLocale.LCID]).ToList(); if (list.Count != 0) this.View.GetControl<ComboFieldEditor>("FCombo").SetComboItems(list); }这段里两个参数容易错:FUNCONTROL_BULK_EDIT是位标志,必须用按位与判断,不能写==;Appearance.VIS_EDIT同样是位组合,判断的是「编辑态可见」这一位,而不是整个Visible值。
4.2 状态管理器:哪些赋值算「脏」,为什么要区分
数据包创建阶段的赋值和绑定之后的赋值,走的是两条路。
CreateNewData只在新增时触发,打开表单不触发。我们在 IDE 里画好单据和基础资料后不写任何代码,打开界面就能看到一张新的空单据,原因就是新建时会创建空数据。要造带缺省值的数据,或者新增时从其他服务取数显示过来,就在这个事件里替换e.BizDataObject:
public override void CreateNewData(BizDataEventArgs e) { // 用当前界面的元数据构造一个空数据对象,替掉框架默认创建的包 e.BizDataObject = new DynamicObject( this.View.OpenParameter.FormMetaData.BusinessInfo.GetDynamicObjectType()); BusinessInfo info = this.View.OpenParameter.FormMetaData.BusinessInfo; // 一次性定位实体和字段,循环里不再重复查找 Entity resultEntity = info.GetEntity("FEntity"); Field seqField = info.GetField("FSeq"); Field nameField = info.GetField("FName"); Field statusField = info.GetField("FStatus"); DynamicObjectCollection rows = (DynamicObjectCollection)resultEntity.DynamicProperty.GetValue(e.BizDataObject); int row = 0; foreach (OperateResult rowResult in _results) { DynamicObject rowData = new DynamicObject(resultEntity.DynamicObjectType); seqField.DynamicProperty.SetValue(rowData, row + 1); nameField.DynamicProperty.SetValue(rowData, rowResult.Name); statusField.DynamicProperty.SetValue(rowData, rowResult.SuccessStatus ? "1" : "0"); rows.Add(rowData); row++; } }这里_results是插件自己持有的操作结果集合,GetDynamicObjectType()保证新包的类型和界面元数据一致。因为这段代码属于「创建数据包」,设置的值不会加到状态管理器中,整个数据包是一起发送到客户端的——客户端上可以用 HTTP 数据监控工具看到这一整包。
BeforeBindData同理:可以在数据绑定前对数据处理,对数据的修改不会被状态管理器记录。典型用法是根据类型决定挂哪个分录体:
public override void BeforeBindData(EventArgs e) { base.BeforeBindData(e); if (_modelTypeId == ElementType.ELEMENTTYPE_BASE.ToString()) { this.View.Model.CreateNewEntryRow("FSearchControl"); } else if (_modelTypeId == ElementType.ELEMENTTYPE_BILL.ToString()) { this.View.Model.CreateNewEntryRow("FFieldParamControl"); } // 操作参数分录,与类型无关,恒加一行 this.View.Model.CreateNewEntryRow("FPARAMOPERATION"); }批量加行别在循环里逐行调,用this.Model.BatchCreateNewEntryRow(string key, int rowCount)一次给够行数,性能和状态管理都干净。
区别到这里就出来了:数据包创建阶段和绑定前的赋值,是「这个包出厂就这样」,服务端不认为发生过修改;AfterBindData之后的赋值走状态管理器,会被记成脏数据,跟着保存、提交一起落到数据库。想清楚你要的是「初始状态」还是「一次修改」,再决定写在哪个回调里,这个判断能省掉大量排查时间。
4.3 AfterCreateNewEntryRow 的触发条件与插入行陷阱
AfterCreateNewEntryRow在新增分录行后触发,通常用于新增分录后对数据做判断处理。有两个细节必须记住。
第一,每次新增分录都会触发,包括批量新增的每一行。如果某段逻辑只需在整单新建后跑一次,放在AfterCreateNewData里一次性处理,别放在这里,否则行数一多就是重复计算。
第二,插入行和追加行的行为不一样。下面这段是给工序号自动赋值,规则是上取整((MAX(工序号)+1)/10)*10,且不超过 9999:
public override void AfterCreateNewEntryRow(CreateNewEntryEventArgs e) { base.AfterCreateNewEntryRow(e); if (!e.Entity.Key.Equals(CONST_ENG_Route.CONST_FSubEntity.ENTITY_FSubEntity)) return; IEnumerable<DynamicObject> subEntryDataCol = this.Model.GetEntityDataObject(e.Entity); // 只有新行落在末尾时才赋值;插入到中间的行不参与 if (e.Row != subEntryDataCol.Count() - 1) return; int maxOperNumber = subEntryDataCol .Select(w => w.GetDynamicObjectItemValue<int>( CONST_ENG_Route.CONST_FSubEntity.ORM_OperNumber)) .Max(); int newOperNumber = (int)Math.Ceiling(((decimal)maxOperNumber + 1) / 10) * 10; this.Model.SetValue( CONST_ENG_Route.CONST_FSubEntity.KEY_FOperNumber, newOperNumber > 9999 ? 9999 : newOperNumber, e.Row); }e.Row是行索引,subEntryDataCol.Count() - 1是末行索引,两者相等说明是追加而非插入。这个判断不能省:插入行时如果照样按 MAX 算,会出现新行工序号跳到最后、甚至和已有行重号的情况。另一个坑是空集合上直接.Max()会抛异常,第一次新增行时必须保证集合里已有初始行,或者加空判断兜底。
5. 避坑与排查:编译通过不等于能跑
环境配置这一块的坑,我按「现象 → 原因 → 解决」记了几条,都是配置层面最容易漏的。
5.1 编译成功但界面上毫无反应
现象:VS 里生成成功,dll 也确实生成了,重新打开单据没有任何变化,菜单点不动,断点更是不进。
原因通常有三个。一是编译输出路径没指向K3CloudServer\Bin,dll 生成在了工程默认的bin\Debug下,产品运行时按约定去 Bin 目录找类型,自然找不到。二是插件只在工程里写了,没在 IDE 设计器的单据属性里注册——不进插件列表,运行时就不会加载。三是个人环境连的还是本地自带的管理中心,元数据和公共环境不一致,注册信息根本没同步过来。
解决:先把工程输出路径改成产品安装目录下的K3CloudServer\Bin;再打开单据属性窗口,进入插件列表点注册、浏览选择编译好的组件、勾选确定,最后保存单据(这一步最容易漏,不保存等于没注册);最后检查个人环境的K3CloudServer\App_Data\Common.config,把管理中心地址改成公共环境建立的管理中心 IP:
<!-- 指向公共环境的管理中心,个人环境才能拿到同一套元数据 --> <add key="managementSiteUrl" value="http://192.168.73.40:8000/" />5.2 排序规则与字符集:装库时最容易忽略的两个参数
现象:单据能打开,但中文显示成乱码,或者按名称查询时结果莫名少几条。
原因:SQL Server 安装时排序规则不是Chinese_PRC_CI_AS,或者 Oracle 的数据库字符集不是AL32UTF8、国家字符集不是AL16UTF16。这两项是建库时定的,装完再改代价极大。
解决:装库之前就把这两项定死——SQL Server 选Chinese_PRC_CI_AS,Oracle 字符集选AL32UTF8、国家字符集选AL16UTF16。已经装错的,别想着打补丁绕过去,重建实例比后期排查乱码便宜得多。
5.3 注册列表被动过之后,业务数据为什么会对不上
现象:为了「清理干净」把插件列表里几条看着眼生的记录删了,之后某些单据保存报错,或者算出来的金额跟以前不一样。
原因:那个列表里注册的插件在运行时会动态加载,删插件不是删配置,是删运行逻辑。别人(或者产品自带的扩展)依赖的插件被移除,等于运行时行为被改了。
解决:注册插件时先看清列表里已有什么,只做「追加」,不做「清理」。确实要停用某个插件,用取消勾选而不是删除记录;并且改动前后各跑一遍关键单据的保存和提交,对比结果。
5.4 调试器挂不上、页面白屏
现象:附加进程找不到浏览器宿主,或者页面直接白屏,控制台什么都没有。
原因:客户端环境不匹配。BOS 客户端依赖浏览器插件,IE 需要 8.0 / 9.0(32 位 x86 和 64 位 x64 都有对应版本),Firefox 12 及以上只支持 32 位 Windows 版本,插件是 Silverlight 5;服务端 IIS 要 6.0 以上、.NET Framework 要 4.0。位数不对或者 Silverlight 版本低于 5,宿主进程根本起不来。
解决:按上面这张对照表逐项核对,不要用 64 位浏览器去配 32 位插件。调试时用HttpWatch Pro这类抓包工具看请求响应,比在 IDE 里瞎猜快得多——尤其是排查「数据到底有没有发到客户端」这类问题,抓一次包比读十遍代码有用。
6. 设计取舍:配置优先还是代码优先
写到这里会发现一个反复出现的判断:同一个需求,IDE 里配实体服务规则能实现,写插件也能实现,选哪个。我的取舍标准是三条。
第一,看变更频率。字段缺省值、显隐控制这类规则,业务人员自己会改,就该配在 IDE 里;配完即时生效,不用重新编译、不用碰 Bin 目录。写成插件,每次调整都得走一遍开发-编译-部署,改一次骂一次。
第二,看是否需要跨对象协调。实体服务规则的作用域在单据内部,一旦要按当前组织去另一张表取缺省值,就必须写代码。前面那个「新增单据时按当前组织取邮件缺省值」的例子就是这样:先用OQLFilter按FORGID过滤出配置行,再把结果写回数据包。配置解决不了跨对象取数,这时候插件是唯一解。
第三,看副作用边界。AfterBindData里改可见性、停用某个菜单项,这些是纯表现层的事,影响面锁在界面上,写代码风险低。而在AfterCreateNewEntryRow里给字段赋值,会进状态管理器、会随保存落库,一旦算错就是数据问题。凡是会落库的赋值,先问自己:这段逻辑能不能用实体服务规则表达?能就去配置,不能再用代码,并且一定在提交前手动验一遍算出来的值。
还有一个常被忽略的取舍:继承哪一层基类。单据插件继承AbstractBillPlugIn,基础资料继承AbstractBasePlugIn,看起来是按对象类型选,实际上是按「你需要哪些事件」选。基类越具体,提供的事件越多,但绑定的语义也越死。做通用扩展时往上提到AbstractDynamicFormPlugIn反而更稳。
提示:判断插件该放 View 层还是 Model 层,用一句话测试——「没人操作的时候这件事还会发生吗」。会,写 Model 层;不会,写 View 层。这条判据我用了很久,比翻文档快。
验证方法也给一个:改完插件不要只看界面。打开 HTTP 数据监控看一眼请求体里带没带上你要的那个字段值,再点一次保存看落库结果对不对。界面正确而数据不对,是最难查的那类问题——它不会报错,只会在某次对账时突然出现。
说个自己的教训。早期做单据扩展,我把一个缺省值赋值写在AfterBindData里,测试时一切正常,界面上值都在;上线后财务对账发现部分单据的某个金额字段跟界面显示不一致。查了很久才明白,那个字段在保存时会被另一条实体服务规则重算,而我写在绑定后的值走了状态管理器,被当成用户手工修改,两边打架。从那以后我每次给字段赋值前,都强制走一遍「这个值是初始状态还是用户修改」的判断,能挪到CreateNewData或AfterCreateNewData的绝不放在绑定之后。希望帮到你。
本文还有配套的精品资源,点击获取