后端逻辑开发
前言
后端在整个软件中的职责是实现数据的保存、读取和脚本的生成。插件的后端也是同理,插件安装到 AuboStudio (以下称为“主服务”)后,主服务会在用户执行一些操作时调用插件中对应的函数。这意味着插件必须实现这几个固定函数才能完成数据的保存、读取和脚本的生成。简言之,开发一个插件后端的过程就是在实现这几个固定函数。
文件介绍
main.ts仅用来实现插件后端对象的导出,请勿对此文件进行修改。webscope_cap.impl.ts是插件与主服务交互的入口文件。主服务加载插件时会实例化此文件中导出的类,此文件导出的类必须实现@aubo/wcaps包中WebscopeCapInterface接口定义才能被正确的加载。installation和program文件夹用来收纳配置和程序模块各节点的文件,可以按需对这两个文件夹中的节点进行增删。
入口文件
前面说到 webscope_cap.impl.ts 中导出的类会被主服务实例化,在主服务中这个类是单例存在的。可以将这个类理解为插件与主服务完成数据传递的桥梁,两者在交换数据时必须按照一套提前约定好的规范进行,否则双方都无法正确识别对方传递的数据。@aubo/wcaps 就是一个专门定义数据结构、函数规范的 npm 包。在下面的介绍中你将逐渐了解这个包的作用。
通过 wcaps-builder 创建出来的项目中, webscope_cap.impl.ts 已经完成了这一文件的基础配置。先来看一下这个文件的全貌:
import { WebscopeService } from "@aubo/wcaps/lib/backend/webscope.service";
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
import { InstallationNodeService } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node-service";
import { ProgramNodeService } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import { ProgramNodeServiceImpl } from "./program/node1/program-node-service";
import { InstallationNodeServiceImpl } from "./installation/node1/installation-node-service";
export class MyWebscopeCap implements WebscopeCapInterface {
webscopeService: WebscopeService;
readonly _rpcMap: Map<string, Function> = new Map();
private programNode1 = new ProgramNodeServiceImpl();
private installNode1 = new InstallationNodeServiceImpl();
constructor(service: WebscopeService) {
this.webscopeService = service;
}
getProgramNodeServie(): ProgramNodeService[] {
return [this.programNode1];
}
getInstallationNodeServices(): InstallationNodeService[] {
return [this.installNode1];
}
getRpcMap(): Map<string, Function> {
return this._rpcMap;
}
}下边将逐项讲解这个文件中每一行代码的作用:
前文提到主服务会实例化这个类,在实例化时主服务会给这个类传递一些常用工具。这些工具可以帮助插件获取主服务中的数据、调用主服务的能力,例如获取当前机械臂位姿、脚本运行状态、向日志文件中打印信息等。
import { WebscopeService } from "@aubo/wcaps/lib/backend/webscope.service";
// ……
export class MyWebscopeCap {
webscopeService: WebscopeService;
constructor(service: WebscopeService) {
this.webscopeService = service;
}
// ……
}只需要在这个类的构造函数中声明一个入参即可完成工具的接收,这个工具是 WebscopeService 的实现类,在 @aubo/wcaps 中有声明。可以通过 WebscopeService 的定义直观的看到主服务给插件传递的工具有哪些能力。你可以将这个工具自由的分发到插件中其他的类中,在插件的任意位置使用它。
上面提到的这一点保证了主服务给插件传递的数据可以被插件正确识别。为了保证插件给主服务传递的数据可以被主服务正确识别,要求插件导出的类必须实现 @aubo/wcaps 包下的 WebscopeCapInterface 接口定义。
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
export class MyWebscopeCap implements WebscopeCapInterface {}实现对应函数后,主服务就可以正确拿到数据完成插件的加载与后续逻辑的交互。
import { InstallationNodeService } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node-service";
import { ProgramNodeService } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
// ……
export class MyWebscopeCap implements WebscopeCapInterface {
// ……
getRpcMap(): Map<string, Function> {
return new Map();
}
getProgramNodeServie(): ProgramNodeService[] {
return [];
}
getInstallationNodeServices(): InstallationNodeService[] {
return [];
}
}到这里相信你对 @aubo/wcaps 包有了一定了解,主要包含以下两点作用:
- 定义主服务给插件提供了哪些工具,这些工具都有什么能力
- 定义插件需要实现哪些函数,这些函数都返回什么数据
主服务给插件传递的工具 WebscopeService 当前声明的能力包括:
| 分类 | 获取方式 | 用途 |
|---|---|---|
| 机械臂交互 | getRobotProxy() | 通过 aubo_sdk 获取机械臂数据、控制机械臂运动 |
| 日志 | getLogger() | 向日志文件写入内容 |
| 与App通信 | getWebsocket() | 主动向前端推送数据 |
| 系统数据 | getSystemApi()、getScopeSetting()、getDataPool() | 获取设备信息、用户设置和其他共享数据 |
| 配置数据 | getTcpModel()、getIoModel()、getFeatureModel()、getPayloadModel() | 获取配置模块TCP、IO、坐标系、负载等数据 |
| 程序数据 | getWaypointModel()、getVariableModel()、getTimerModel()、getLoopModel() | 获取程序模块路点、变量、定时器、循环等数据 |
插件需要提供给主服务的函数及作用:
| 函数名称 | 用途 |
|---|---|
getRpcMap() | 将需要给插件前端提供的接口通过此函数注册到主服务中,以实现插件前后端的数据交互 |
getInstallationNodeServices() | 将需要注册到主服务中的配置节点通过此函数暴露出去,主服务会将这些节点当作配置模块中一个节点处理 |
getProgramNodeServie() | 将需要注册到主服务中的程序节点通过此函数暴露出去,主服务会将这些节点当作程序模块中一个节点处理 |
配置节点
配置节点在主服务中只会被实例化一次,常用来存放一些全局参数。可以将配置节点理解为给即将运行的程序做准备,之后的实体机操作都是基于配置节点中参数运行,所以配置节点生成的脚本会在程序节点的上方。一个配置节点应由 service 、config 和 node 三个类组成。其中 service 用来做后两者的实例化和导出,config中用于保存一些基础信息,node 是核心逻辑类。先来看下一个 node 类的全貌:
import { ScriptWriter, DataModel } from "@aubo/wcaps/lib/backend/domain";
import { InstallationNode } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node";
export class InstallationNodeImpl implements InstallationNode {
dataModel: DataModel | undefined;
generateScript(scriptWriter: ScriptWriter): void {
scriptWriter.appendLine("-------- Fill in the Lua script --------");
}
setModel(dataModel: DataModel) {
this.dataModel = dataModel;
}
}配置节点中主服务与插件的核心交互有两个:setModel() 与 generateScript() 。用户执行 打开配置文件 和 运行程序 这两个动作时主服务会调用这两个函数。当用户打开一个配置文件时,主服务会把配置文件中属于该节点的数据分发到此。分发的数据结构是 DataModel ,其内部存在一个 Map 用于保存数据。在用户触发 保存配置文件 时,主服务会把这个 DataModel 中数据写入到配置文件中。需要持久化的数据可以存放到这个 DataModel 中。
generateScript() 顾名思义是用于生成脚本的函数,这个函数的入参 ScriptWriter 在 @aubo/wcaps 包中也有定义,使用这个工具可以便捷的生成脚本。
程序节点
程序节点也是由 service 、config 和 node 三个类组成,对比配置节点程序节点就要复杂很多。config 类中配置详解如下:
| 属性名 | 用途 | 数据类型 | 是否必选 | 默认值 |
|---|---|---|---|---|
id | 表示节点身份,必须与插件前端中配置一致,不能与内置节点重名。 | string | 是 | |
beforeNodes | 配置允许作为本节点直接前驱的节点类型,用于限制兄弟节点顺序 | Array<ProgramNodeId> | 是 | |
parentNodes | 配置允许作为本节点直接父节点的类型,用于限制本节点必须放在哪个节点下 | Array<ProgramNodeId> | 是 | |
childNodes | 配置允许成为本节点直接子节点的类型,用于限制本节点下可插入的节点类型 | Array<ProgramNodeId> | 是 | |
ancestorsNodes | 配置祖先链中必须存在的节点类型,用于限制本节点只能出现在某种上下文内 | Array<ProgramNodeId> | 是 | |
isChildrenAllowed | 表示是否必须有子节点,如果配置为 true 则此节点下为空时会自动补 EmptyNode 用于占位 | boolean | 否 | false |
isInsertDisabled | 表示是否禁止向此节点下插入子节点,如果配置为 true 则不允许在此节点下插入节点 | boolean | 否 | false |
正确配置此文件即可实现程序节点的权限控制。
程序节点的 service 类是主服务管理这一类程序节点的入口。前文提到,一个程序节点的 service 只会实例化一次,而用户每在程序树上创建一个节点,就会产生一个新的 node 实例。创建这些 node 实例、返回节点的静态配置、恢复已保存节点和复制节点,都是 service 类的职责。
通过 wcaps-builder 创建出来的项目中已经包含一个基础实现:
import { DataModel } from "@aubo/wcaps/lib/backend/domain";
import { ProgramNode } from "@aubo/wcaps/lib/backend/interface/program/node/program-node";
import { ProgramNodeConfig } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-config";
import {
CreateNodeRes,
ProgramNodeService,
} from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import {
ProgramModel,
TreeNodeModel,
} from "@aubo/wcaps/lib/backend/interface/program/program-model";
import TreeModel from "tree-model";
import { ProgramNodeImpl } from "./program-node";
import { ProgramNodeConfigImpl } from "./program-node-config";
export class ProgramNodeServiceImpl implements ProgramNodeService {
config: ProgramNodeConfigImpl = new ProgramNodeConfigImpl();
programNodeModel: ProgramModel;
setProgramModel(programNodeModel: ProgramModel): void {
this.programNodeModel = programNodeModel;
}
getProgramNodeConfig(): ProgramNodeConfig {
return this.config;
}
createNode(parentId: string): CreateNodeRes {
return {
nodeId: this.config.id,
node: new ProgramNodeImpl(this.programNodeModel),
children: [],
};
}
loadFromXml(dataModel: DataModel): ProgramNode {
const node = new ProgramNodeImpl(this.programNodeModel);
node.setDataModel(dataModel);
return node;
}
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
return new ProgramNodeImpl(this.programNodeModel).cloneNode(node);
}
}service 类必须实现 ProgramNodeService 接口。主服务加载插件时,会通过入口类的 getProgramNodeServie() 获得这个对象,然后根据 getProgramNodeConfig() 返回的 id 将它注册到程序节点工厂中。之后主服务需要创建、加载或复制这一类节点时,就可以通过这个 id 找到对应的 service。
需要区分 service、config 和 node 三种对象的实例数量:
| 对象 | 实例数量 | 保存的内容 |
|---|---|---|
service | 每一种程序节点一个 | 节点生命周期管理逻辑,以及主服务提供的 ProgramModel |
config | 每一种程序节点一个 | 节点类型 ID、结构约束和操作权限等静态配置 |
node | 程序树上每创建一个节点就产生一个 | 当前节点独立的业务数据、页面配置和最终需要生成的 Lua 脚本 |
因此,不要把某个具体程序节点的可变数据保存在 service 或 config 中。否则程序树上的同类型节点会共用这份数据,修改其中一个节点就可能影响其他节点。每个节点自己的参数应保存在对应的 node 实例及其 DataModel 中。
主服务在注册程序节点 service 时,会调用 setProgramModel(),将当前程序的模型传递进来:
programNodeModel: ProgramModel;
setProgramModel(programNodeModel: ProgramModel): void {
this.programNodeModel = programNodeModel;
}ProgramModel 是插件访问当前程序上下文的入口,可以用来获取程序树和其他节点的 service。service 创建出的所有 node 实例也需要使用同一个 ProgramModel,因此应先将它保存下来,再在 createNode()、loadFromXml() 和 cloneNode() 中传给 node 的构造函数。
这个函数由主服务负责调用,插件不需要在入口类中手动调用。需要注意,在 setProgramModel() 被调用之前不要创建依赖 ProgramModel 的 node 实例。
getProgramNodeConfig() 用于向主服务提供这一类节点的静态配置:
config: ProgramNodeConfigImpl = new ProgramNodeConfigImpl();
getProgramNodeConfig(): ProgramNodeConfig {
return this.config;
}主服务会使用这里返回的配置完成这类节点的加载,这里应始终返回同一个 config 对象。config.id 也必须与 createNode() 返回的 nodeId 一致,并与插件前端注册程序节点页面时使用的 ID 一致。
用户从页面向程序树中添加节点时,主服务会调用 createNode():
createNode(parentId: string): CreateNodeRes {
return {
nodeId: this.config.id,
node: new ProgramNodeImpl(this.programNodeModel),
children: [],
};
}入参 parentId 是即将承载新节点的父节点实例 ID。它不是父节点的类型 ID,也不是当前节点配置中的 id。对于一个不依赖父节点的普通节点,可以像模板一样不使用它;如果节点的默认数据需要根据所在父节点初始化,则可以通过 ProgramModel 查询父节点信息。
返回值 CreateNodeRes 包含三个字段:
| 字段 | 用途 |
|---|---|
nodeId | 新节点的类型 ID,应返回 config.id |
node | 本次新建的 node 实例,每次调用都必须创建一个新对象 |
children | 创建节点时需要一并创建的默认子节点;普通节点返回空数组,主服务会递归处理非空的子节点 |
这里不需要为 node 生成程序树实例 UUID,也不需要主动创建 DataModel。主服务把返回的节点加入程序树时会完成这两项工作,并调用 node.setId() 与 node.setDataModel()。nodeId 表示节点类型,同类型节点共用它;实例 UUID 则用来区分程序树上每一个具体节点。
如果创建节点时必须带有固定的默认子结构,可以在 children 中继续返回 CreateNodeRes:
createNode(parentId: string): CreateNodeRes {
const childNode = new ChildProgramNodeImpl(this.programNodeModel);
return {
nodeId: this.config.id,
node: new ProgramNodeImpl(this.programNodeModel),
children: [
{
nodeId: "my-plugin-child",
node: childNode,
children: [],
},
],
};
}children 适合描述节点创建时必须同时生成的固定结构。仅仅希望节点为空时显示一个占位节点,不需要手动返回 EmptyNode,在 config 中配置 isChildrenAllowed = true 后,主服务会自动补充。
用户打开已经保存的程序文件时,主服务会读取属于该节点的数据,并调用 loadFromXml() 恢复一个 node 实例:
loadFromXml(dataModel: DataModel): ProgramNode {
const node = new ProgramNodeImpl(this.programNodeModel);
node.setDataModel(dataModel);
return node;
}这里需要完成两件事:
- 创建一个新的
node实例; - 调用
setDataModel(),把主服务从程序文件中恢复出的数据交给它。
不要在这里返回 service 中提前保存的某个 node 对象。一个程序文件中可能存在多个同类型节点,每次调用都应返回一个互相隔离的新实例。节点实例 ID、标题和在程序树中的层级关系由主服务在后续加载流程中恢复,loadFromXml() 的核心职责是恢复 node 内部的业务数据。
createNode() 与 loadFromXml() 的区别是:前者创建一个使用默认值的新节点,后者创建一个使用持久化数据恢复的旧节点。
用户复制、粘贴节点,以及部分撤销重做操作,需要通过 cloneNode() 克隆已有节点:
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
return new ProgramNodeImpl(this.programNodeModel).cloneNode(node);
}TreeNodeModel 中不仅包含当前 node,还包含节点的类型、抑制状态、断点状态和子节点等程序树信息。因此克隆工作最终由 node 类中的 cloneNode() 完成,service 负责创建一个新的目标实例并把原节点数据传递给它。
这里同样必须先 new 一个新的 ProgramNodeImpl。不能直接返回原节点,也不要只做对象的浅拷贝,否则复制后的节点可能和原节点共用 DataModel 或子节点数据。对于允许拥有子节点的节点,node.cloneNode() 还需要递归调用各子节点类型对应的 service.cloneNode(),这一部分将在 node 类中详细介绍。
至此,service 类完成了同一类程序节点的四项管理工作:向主服务提供静态配置、接收程序上下文、创建新节点、恢复和克隆已有节点。下一步需要在 node 类中实现每一个节点实例真正的数据更新、持久化与脚本生成逻辑。
node 类是程序节点的核心逻辑类。程序树上每创建一个节点,主服务就会通过 service 创建一个新的 node 实例。这个实例负责保存当前节点自己的参数、接收前端修改、判断配置是否完整、生成 Lua 脚本,以及在保存程序时提供需要持久化的数据。
为了便于理解,下面以一个拥有 message 参数、运行时向机器人日志输出消息的节点为例,展示 node 类的完整结构:
import { DataModel, ScriptWriter } from "@aubo/wcaps/lib/backend/domain";
import { ProgramNode } from "@aubo/wcaps/lib/backend/interface/program/node/program-node";
import {
ProgramModel,
ProgramNodeData,
ProgramNodeId,
ProgramTreeNodeData,
TreeNodeModel,
} from "@aubo/wcaps/lib/backend/interface/program/program-model";
import TreeModel from "tree-model";
export class ProgramNodeImpl implements ProgramNode {
private id: string;
private dataModel: DataModel;
private programModel: ProgramModel;
private readonly messageKey = "message";
private message = "Hello AUBO";
constructor(programModel: ProgramModel) {
this.programModel = programModel;
}
setId(id: string): void {
this.id = id;
}
update(config: ProgramNodeData): boolean {
for (const key in config) {
if (key === this.messageKey) {
this.message = String(config[key]);
}
}
return true;
}
dropNode(): void {}
toJSON(): ProgramTreeNodeData {
return {
message: {
value: this.message,
hasDefined: this.message.trim().length > 0,
},
};
}
getTitle(): string {
return this.message ? `Message: ${this.message}` : "Message";
}
hasDefined(): boolean {
return this.message.trim().length > 0;
}
generateScript(scriptWriter: ScriptWriter): void {
scriptWriter.appendLine(`textmsg(${JSON.stringify(this.message)})`);
}
setDataModel(dataModel: DataModel): void {
this.dataModel = dataModel;
this.message = dataModel.get(this.messageKey, this.message);
}
getDataModel(): DataModel {
this.dataModel.set(this.messageKey, this.message, "String");
return this.dataModel;
}
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
const treeModelInstance = new TreeModel();
const parseTree: TreeModel.Node<TreeNodeModel> = treeModelInstance.parse({});
const { children, programNode } = node;
const nodeData = programNode.getDataModel();
this.setDataModel(nodeData);
const currentNode = this.programModel
.getProgramTree()
.addChild(parseTree, node.nodeId, this, 0);
currentNode.model.disabled = node.disabled;
currentNode.model.isBreakpoint = node.isBreakpoint;
currentNode.model.suppressedComment = node.suppressedComment;
if (children && children.length > 0) {
for (const child of children) {
const childTree = this.programModel
.getProgramNodeFactory()
.getProgramNodeServiceById(<ProgramNodeId>child.nodeId)
.cloneNode(child);
currentNode.addChild(childTree);
}
}
return currentNode;
}
}一个 node 实例中主要存在三类数据:
| 数据 | 来源 | 用途 |
|---|---|---|
id | 主服务调用 setId() 注入 | 标识程序树上的某一个具体节点实例 |
programModel | service 创建节点时传入 | 访问当前程序树以及其他程序节点的 service |
message 等业务字段 | 默认值、前端修改或程序文件 | 决定页面显示内容、节点完整状态和最终生成的 Lua 脚本 |
dataModel | 主服务调用 setDataModel() 注入 | 保存需要随程序文件一起持久化的数据 |
其中业务字段是节点运行期间直接使用的数据,DataModel 是这些业务字段的持久化载体。开发时需要保证两者能够相互转换:
打开程序文件:DataModel → setDataModel() → 业务字段
保存程序文件:业务字段 → getDataModel() → DataModel构造函数只负责接收程序上下文,并为业务字段设置合理的默认值:
private programModel: ProgramModel;
private message = "Hello AUBO";
constructor(programModel: ProgramModel) {
this.programModel = programModel;
}这里通常不需要创建 DataModel,也不需要生成节点 ID。主服务将节点加入程序树时会为它创建 DataModel,同时生成实例 UUID,然后依次调用 setId() 和 setDataModel()。
programModel 可以用来访问程序树以及其他节点的 service。普通的独立节点可能只在克隆时使用它;需要查询父节点、查找其他节点或管理子节点时,也可以通过它访问当前程序上下文。
setId() 接收主服务生成的节点实例 ID:
private id: string;
setId(id: string): void {
this.id = id;
}这里的 id 是程序树上某一个具体节点的 UUID,与 config.id 的含义不同:
| ID | 是否由插件定义 | 是否被同类型节点共享 | 用途 |
|---|---|---|---|
config.id | 是 | 是 | 标识节点类型、注册 service |
node.id | 否 | 否 | 标识程序树上的具体节点实例 |
插件只需保存这个值,不要自行重新生成或覆盖。节点被复制或通过撤销重做恢复时,主服务也可能再次调用 setId() 更新实例 ID。
用户在前端修改节点参数后,主服务会把修改后的键值对传给 update():
update(config: ProgramNodeData): boolean {
for (const key in config) {
if (key === this.messageKey) {
this.message = String(config[key]);
}
}
return true;
}ProgramNodeData 是普通的键值对象,例如:
{
message: "Start picking"
}update() 中应根据字段名称完成类型转换、合法性检查和业务字段更新。字段名称需要与前端提交的数据保持一致。一个节点拥有多个配置项时,可以继续增加判断:
if (key === this.messageKey) {
this.message = String(config[key]);
} else if (key === this.countKey) {
this.count = Number(config[key]);
} else if (key === this.enabledKey) {
this.enabled = config[key] === true || config[key] === "true";
}不要直接把整个前端对象覆盖到 node 实例上。前端数据需要经过类型转换和校验,无法识别的字段应忽略。尤其不要直接使用 Boolean("false") 转换字符串,因为它的结果仍然是 true。update() 返回 true 表示本次更新成功;无法接受输入时可以返回 false。更新完成后主服务会重新调用 hasDefined()、getTitle() 和 toJSON(),并把节点的新状态同步给前端。
toJSON() 用来描述节点当前提供给前端的数据及每一项数据是否完整:
toJSON(): ProgramTreeNodeData {
return {
message: {
value: this.message,
hasDefined: this.message.trim().length > 0,
},
};
}每个配置项都包含:
| 字段 | 用途 |
|---|---|
value | 当前值,前端可以用它还原节点页面 |
hasDefined | 当前配置项是否有效、是否已经完成必要配置 |
toJSON() 返回的是主服务和前端交互时使用的节点数据快照,不是程序文件的持久化格式。真正写入程序文件的数据来自 getDataModel()。为了避免页面状态与持久化状态不一致,toJSON()、update() 和 DataModel 中相同参数应使用一致的字段名。
getTitle() 返回程序树中显示的节点标题:
getTitle(): string {
return this.message ? `Message: ${this.message}` : "Message";
}标题可以是固定文本,也可以根据当前业务字段动态生成。主服务会在创建节点、加载程序以及节点数据发生改变后重新获取标题,因此不需要在 update() 中直接修改程序树上的 title。
标题应该尽量简短,并能让用户在不打开节点页面的情况下识别当前配置。例如:
Message: Start picking
Wait: 2s
Set output: DO_01 = HighhasDefined() 判断当前节点是否已经完成必要配置:
hasDefined(): boolean {
return this.message.trim().length > 0;
}返回 false 时,主服务会把节点标记为未完整定义;程序树上的父节点也可能因为子节点未定义而变成未定义状态。生成脚本时,主服务只会调用未被抑制并且已经定义完成的节点。
因此这个函数应检查所有生成脚本必需的参数,而不应固定返回 true。例如一个节点必须同时配置输出端口和输出状态,可以写成:
hasDefined(): boolean {
return !!this.output && this.outputValue !== undefined;
}toJSON() 中每个字段的 hasDefined 描述单项配置状态,node.hasDefined() 则给出整个节点的最终状态。
generateScript() 负责把当前节点的业务参数转换成 Lua 脚本:
generateScript(scriptWriter: ScriptWriter): void {
scriptWriter.appendLine(`textmsg(${JSON.stringify(this.message)})`);
}ScriptWriter 常用函数包括:
| 函数 | 用途 |
|---|---|
appendLine(script) | 追加一行脚本,并使用当前缩进 |
appendRaw(script) | 原样追加一段脚本 |
note(comment) | 写入注释 |
writeChildren() | 在当前位置递归生成当前节点的所有子节点脚本 |
increaseIndent() | 增加缩进 |
decreaseIndent() | 减少缩进 |
ifCondition()、end() | 生成条件结构 |
sleep(seconds) | 生成等待指令 |
如果当前节点是一个拥有子节点的容器,必须在合适的位置调用 writeChildren(),否则子节点虽然存在于程序树中,却不会生成脚本。例如一个只负责组织子节点的容器可以写成:
generateScript(scriptWriter: ScriptWriter): void {
scriptWriter.note("my container start");
scriptWriter.writeChildren();
scriptWriter.note("my container end");
}不要未经处理就把用户输入拼接进 Lua 字符串,否则引号、换行符等内容可能破坏脚本结构。示例使用 JSON.stringify(this.message) 对字符串进行转义;对于数字和布尔值,也应先完成合法性检查再生成脚本。
setDataModel() 与 getDataModel() 共同完成节点数据的读取和保存:
setDataModel(dataModel: DataModel): void {
this.dataModel = dataModel;
this.message = dataModel.get(this.messageKey, this.message);
}
getDataModel(): DataModel {
this.dataModel.set(this.messageKey, this.message, "String");
return this.dataModel;
}setDataModel() 的数据方向是从 DataModel 恢复业务字段。调用 get() 时第二个参数是默认值:程序文件中没有对应字段时,会保留当前默认值。这一点可以让后续版本新增字段时仍然兼容旧程序文件。
getDataModel() 的数据方向是把业务字段写回 DataModel。主服务保存程序文件前会调用这个函数,然后将返回的数据序列化到程序文件中。
DataModel.set() 的第三个参数表示数据写入 XML 时使用的类型标签。常见值包括:
| TypeScript 数据 | 类型标签示例 |
|---|---|
string | "String" |
boolean | "Bool" |
| 整数 | "Int32" 或 "Int64" |
| 小数 | "Float32" 或 "Float64" |
Array<string> | "VectorString" |
Array<number> | "VectorDouble" |
| 表达式、IO、路点等对象 | "Expression"、"Io"、"Waypoint" |
保存和读取同一个字段时,键名和数据类型必须保持一致。不要把同一个键先保存为字符串,后续又按数字读取,否则 DataModel 可能无法正确恢复原值。
节点从程序树中删除时,主服务会调用 dropNode():
dropNode(): void {}普通节点不占用外部资源时可以保持为空。如果节点创建了变量、定时器、订阅、事件监听或其他需要显式释放的资源,应在这里解除引用或清理资源。例如:
dropNode(): void {
this.unsubscribe?.();
}对于带子节点的节点,主服务删除整棵子树时会依次调用每一个节点的 dropNode(),插件不需要在父节点中重复清理普通子节点。
cloneNode() 用于复制当前节点的业务数据、程序树状态以及全部子节点:
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
const treeModelInstance = new TreeModel();
const parseTree: TreeModel.Node<TreeNodeModel> = treeModelInstance.parse({});
const { children, programNode } = node;
const nodeData = programNode.getDataModel();
this.setDataModel(nodeData);
const currentNode = this.programModel
.getProgramTree()
.addChild(parseTree, node.nodeId, this, 0);
currentNode.model.disabled = node.disabled;
currentNode.model.isBreakpoint = node.isBreakpoint;
currentNode.model.suppressedComment = node.suppressedComment;
if (children && children.length > 0) {
for (const child of children) {
const childTree = this.programModel
.getProgramNodeFactory()
.getProgramNodeServiceById(<ProgramNodeId>child.nodeId)
.cloneNode(child);
currentNode.addChild(childTree);
}
}
return currentNode;
}这段代码的流程如下:
- 从原节点的
getDataModel()取得最新业务数据; - 通过当前新实例的
setDataModel()恢复这些数据; - 创建一棵临时树,并让主服务为复制出的节点生成新的实例 ID 和独立的
DataModel; - 恢复原节点的抑制状态、断点状态和抑制备注;
- 如果存在子节点,根据每个子节点的类型 ID 找到对应
service,递归完成克隆。
不能直接返回原来的 TreeNodeModel,也不能让新旧节点长期共用同一个 DataModel,否则复制后修改任一节点可能影响另一个节点。模板中的调用顺序会先读取原数据,再由程序树为新节点建立独立的数据模型。
如果节点注册了变量、路点等具有全局身份的数据,仅复制 DataModel 可能还不够,还需要在克隆时创建新的资源或重新建立引用,具体行为应根据业务决定。
至此,一个程序节点后端已经具备了完整的数据生命周期:
创建节点 → 设置默认值 → 前端调用 update() 修改参数
→ toJSON()/getTitle()/hasDefined() 刷新页面状态
→ getDataModel() 保存程序文件
→ setDataModel() 打开程序文件
→ generateScript() 生成 Lua 脚本
→ cloneNode()/dropNode() 处理复制和删除开发完成后,至少应验证以下场景:
- 连续创建两个同类型节点,修改其中一个不会影响另一个;
- 保存并重新打开程序后,节点参数和标题能够正确恢复;
- 必填参数为空时
hasDefined()返回false,配置完成后恢复为true; - 复制节点后,新旧节点的数据相同但可以分别修改;
- 容器节点能够在正确位置生成所有子节点的脚本;
- 用户输入包含引号、换行等字符时,生成的 Lua 脚本仍然合法;
- 删除节点后,节点创建的外部资源已经正确释放。
