浅析LogicFlow插件bpmn-elements 及 adapter

1,583 阅读9分钟

浅析LogicFlow插件bpmn-elements 及 adapter

最近内部平台要实现bpmn流程图,选用了LogicFlow,在翻阅资料、文档的过程中发现之前就有用LogicFlow替换bpmn.js的文章,同时官方之前就有Bpmn插件来提供节点和数据转换的能力,最近也是更新了新版本,想用用来着,但是相关资料着实很少,文档里也只是粗略的做了介绍,使用方式不大明确。虽然LogicFlow使用相关的问题大部分都是开发者们在交流群里互帮互助解决的,不过还是想给官方提个需求,什么时候完善一下文档,让内容更清楚,让大家看得更方便(狗头。不过为了方便团队内部对插件做后续的更新,同时减少外部依赖,笔者自己看了看、抄了抄官方的代码,在内部重新实现一遍,顺便整理了一些自己在阅读和调试源码的过程中认识和理解,供大家参考,LogicFlow打钱!

文件结构

一开始本来打算直接看LogicFlow里面的bpmn-elements和bpmn-elements-adapter源码,但是找了找发现官方示例里有个bpmn-vue的demo,里面也有这两个插件的内容,怀疑他们是在示例上开发最后把BPMN的内容抽离出来弄成了插件的,所以这里我们可以直接在bpmn-vue项目里阅读和调试插件的源码。 整个项目是用Vue3开发的,先看看src目录下的一些主要文件的结构

.
├── components
│   ├── panels // 属性面板组件相关
│   │   ├── components
│   │   │   ├── condition.vue
│   │   │   ├── index.ts
│   │   │   ├── multiInstance.vue
│   │   │   ├── normal.vue
│   │   │   ├── panels.vue
│   │   │   ├── processRef.vue
│   │   │   └── timerDefinition.vue
│   │   ├── index.ts
│   │   └── index.vue
├── pages
│   └── bpmn.vue
├── plugin // bpmn插件相关
│   ├── bpmn-elements // bpmn节点插件
│   │   ├── README.md
│   │   ├── index.d.ts
│   │   ├── index.ts
│   │   ├── presets
│   │   │   ├── Event // 事件节点
│   │   │   │   ├── EndEventFactory.ts
│   │   │   │   ├── IntermediateCatchEvent.ts
│   │   │   │   ├── IntermediateThrowEvent.ts
│   │   │   │   ├── StartEventFactory.ts
│   │   │   │   ├── boundaryEventFactory.ts
│   │   │   │   └── index.ts
│   │   │   ├── Flow // 流/边
│   │   │   │   ├── flow.d.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── manhattan.ts // polyline 连线生成
│   │   │   │   └── sequenceFlow.ts
│   │   │   ├── Gateway // 网关节点
│   │   │   │   ├── gateway.ts
│   │   │   │   └── index.ts
│   │   │   ├── Pool // 泳道
│   │   │   │   ├── Lane.ts
│   │   │   │   ├── Pool.ts
│   │   │   │   └── index.ts
│   │   │   ├── Task // 任务
│   │   │   │   ├── index.ts
│   │   │   │   ├── subProcess.ts
│   │   │   │   └── task.ts
│   │   │   └── icons.ts
│   │   └── utils.ts
│   ├── bpmn-elements-adapter // bpmn adapter插件
│   │   ├── README.md
│   │   ├── constant.ts
│   │   ├── index.ts
│   │   ├── json2xml.ts
│   │   └── xml2json.ts
│   └── samples // .bpmn 文件
.

这里我把项目的内容大致分为bpmn-elements、数据面板和bpmn-elements-adapter三部分,虽然插件化的只有节点和转换两部分内容,但是数据面板在这个项目里也十分重要,和其他两个插件有比较密切的配合,至于为什么没有把数据面板也做成插件,我猜测是因为大部分情况下我们各自的需求里对面板的要求是不一样的,所以把面板的内容留给了开发者自己,例如在我们内部平台的需求里,面板就需要按照设计好的UI和交互实现。

bpmn-elements

bpmn-elements/index.ts是插件定义的地方

class BPMNElements {
  static pluginName = 'BpmnElementsPlugin';
  constructor({ lf }: any) {
    lf.definition = {};
    lf.useDefinition = useDefinition(lf.definition);
    const [_definition, setDefinition] = lf.useDefinition();
    setDefinition(definitionConfig);

	  // 注册节点
    registerPoolNodes(lf)
    registerEventNodes(lf);
    registerGatewayNodes(lf);
    registerFlows(lf);
    registerTaskNodes(lf);

    lf.setDefaultEdgeType('bpmn:sequenceFlow');
  }
}

插件定义的代码里注册节点的部分内容比较明确,无非是传入lf示例到register方法,在register方法里调用lf.register给节点做统一的注册,不明确的是在构造函数里的lf实例为什么要挂上一个definition对象和一个useDefinition方法,构造函数里调用了useDefintion并用解构出来的setDefintion方法把默认的definitionConfig设置到了lf.defintion上,但是这definition是个啥东西呢,没太看懂?其实我们可以在同级目录下的README和自定义节点中找到答案。

Event

我在全局搜索里一下definition,发现definition只在事件节点的自定义里有用到,所以初步认定definition是专门为事件节点定义的,在事件节点的定义里面可以看到definition的具体使用,以开始事件为例

function StartEventFactory(lf){
  const [definition] = lf.useDefinition();
  class view extends CircleNode {
    getShape() {
      const { icon } = definition.startEvent?.get(definitionType) || {};
		...
	  }
	}
	class model extends CircleNodeModel {
    constructor(data, graphModel) {
      ...
      const { properties = {} } = definition.startEvent?.get(data.properties?.definitionType) || {};
      data.properties = {
        ...properties,
        ...data.properties,
      };
      data.properties?.definitionType
        && (data.properties!.definitionId = `Definition_${genBpmnId()}`);
      super(data, graphModel);
    }
		...
	}
	...
}

开始事件节点内部会从definition中获取icon或者属性。 上面我们初步认定definition是为事件节点设计的,接下来分析分析definition的含义,看看是不是真的像我们认定的那样。根据README中的描述,笔者猜测插件的设计者在一开始做节点划分的时候是根据XML数据中的节点结构来的,以README中的例子为例

<bpmn:boundaryEvent id="BoundaryEvent_1" cancelActivity="false" attachedToRef="Task_1">
  <bpmn:timerEventDefinition>
   <bpmn:timeDuration>
    P1D
   </bpmn:timeDuration>
  </bpmn:timerEventDefinition>
</bpmn:boundaryEvent>

这是一个非中断时间边界事件,我们可以说,它的节点类型是bpmn:boundaryEvent,而它的definition是bpmn:timerEventDefinition,至于bpmn:timerDuration,是通过bpmn-elements-adapter转换出来的,在后面adapter部分可以知晓这一点。 根据这种分类方式,事件节点就可以理解为是由事件类型和definition构成的

event.png

所以这里事件拆分出了开始事件、结束事件、边界时间、捕获事件、抛出事件

├── Event
│   ├── EndEventFactory.ts
│   ├── IntermediateCatchEvent.ts
│   ├── IntermediateThrowEvent.ts
│   ├── StartEventFactory.ts
│   ├── boundaryEventFactory.ts
│   └── index.ts

Gateway

网关节点只有一个用于生成网关节点的工厂函数

├── Gateway
│   ├── gateway.ts
│   └── index.ts

传入网关节点的节点名和icon,就可以返回对应类型的网关

const ExclusiveGateway = GatewayNodeFactory('bpmn:exclusiveGateway', exclusiveIcon);

const ParallelGateway = GatewayNodeFactory('bpmn:parallelGateway', parallelIcon);

const InclusiveGateway = GatewayNodeFactory('bpmn:inclusiveGateway', inclusiveIcon);

在bpmn-elements插件里默认生成了包容网关、并行网关、排他网关三种网关节点

Task

const ServiceTask = TaskNodeFactory('bpmn:serviceTask', serviceTaskIcon);
const UserTask = TaskNodeFactory('bpmn:userTask', userTaskIcon);

任务节点和网关节点的设计类似,但是有一定的差别,差异点是边界事件可以附着在任务节点上,所以任务节点上需要一些和边界事件相关的逻辑。可以说,边界事件附着的行为相关逻辑的关键是注册事件监听的回调,在会回调中执行相关逻辑。

// 注册监听
// 拖拽过程中,检查节点是否在某个任务节点的边界上,并执行相关逻辑
lf.on('node:drag,node:dnd-drag', checkAppendBoundaryEvent);
// 停止拖拽、节点通过DND方法添加时判断节点是否在某一个任务节点的边界上,并执行相关逻辑
lf.on('node:drop,node:dnd-add', appendBoundaryEvent);

lf.graphModel.addNodeMoveRules(
    (
      model: { isTaskNode: any; boundaryEvents: any },
      deltaX: any,
      deltaY: any,
    ) => {
      if (model.isTaskNode) {
        // 如果移动的是分组,那么分组的子节点也跟着移动。
        const nodeIds = model.boundaryEvents;
        lf.graphModel.moveNodes(nodeIds, deltaX, deltaY, true);
        return true;
      }
      return true;
    },
 );

这里主要做三件事情 1、边界事件节点拖拽过程中判断是否在某一个任务节点的边界上 2、停止拖拽时判断时判断边界事件节点是否在某一个任务节点的边界上,如果在则将该边界事件加入到任务节点的boundaryEvents属性上 3、添加一个节点移动规则,如果移动的节点是任务节点则同时移动boundaryEvents里的边界事件节点 这里就不介绍具体的实现了,感兴趣的小伙伴可以把源码下下来里看一下,其能实现的方式很多。

SubProcess

子流程节点继承自Group节点,并且额外添加了和任务节点一样的边界事件附着相关的逻辑,这里就不赘述了。

Flow

顺序流在这里是通过自定义边实现的,在边的基础上增加了panels属性以及一个用于标记缺省流的isDefaultFlow属性

class model extends PolylineEdgeModel {
    static extendKey = 'SequenceFlowModel';
    constructor(data: EdgeConfig, graphModel: GraphModel) {
      if (!data.id) {
        data.id = `Flow_${genBpmnId()}`;
      }
      const properties: SequenceFlowType = {
        ...(props || {}),
        ...data.properties,
        panels: ['condition'],
        isDefaultFlow: false,
      };
      data.properties = properties;

      super(data, graphModel);
    }
  }

class view extends PolylineEdge {
    static extendKey = 'SequenceFlowEdge';
    getStartArrow(): JSX.Element | null {
      const { model } = this.props;
      const { isDefaultFlow } = model.properties;
      return isDefaultFlow
        ? h('path', {
          refX: 15,
          stroke: '#000000',
          strokeWidth: 2,
          d: 'M 20 5 10 -5 z',
        })
        : h('path', {
          d: '',
        });
    }
  }

这里大家也可以按自己的思路实现。

数据面板(panels)

数据面板虽然没有插件化,但是在bpmn-vue这个项目里跟节点以及adapter都有密切的配合,其实现思路也有可取之处。

<template>
  <div>
    <div v-for="item in defaultPanels" :key="item">
      <component :is="components[item]"></component>
    </div>

    <div v-for="item in target.properties.panels" :key="item.key">
      <component :is="components[item]"></component>
    </div>
  </div>
</template>
<script setup lang="ts">
import components from ".";
...
</script>

可以看到,数据面板中会通过panels属性上的值通过动态组件从定义好的map中获取对应的表单并渲染

const components: any = reactive({
  normal: markRaw(defineAsyncComponent(() => import('./normal.vue'))),
  timerDefinition: markRaw(
    defineAsyncComponent(() => import('./timerDefinition.vue')),
  ),
  condition: markRaw(defineAsyncComponent(() => import('./condition.vue'))),
  multiInstance: markRaw(
    defineAsyncComponent(() => import('./multiInstance.vue')),
  ),
  processRef: markRaw(defineAsyncComponent(() => import('./processRef.vue'))),
});

我们要做的是在组件里定义好涉及节点数据更新的逻辑,并写入上面components对象里,最后在对应的节点属性里加上对应的panels。算是种还不错的实现方式,开发者很大程度上只需要只用关心组件内数据如何更新,例如在这里multiInstance组件用来修改任务节点的multi-instance类型

<template>
  <strong class="form-label">MULTI-INSTANCE</strong>
  <div>
    <p class="form-label">Multi-instance Type</p>
    <select
      name="multiInstanceType"
      class="form-select"
      style="margin: 6px 0px"
      :value="target.properties.multiInstanceType"
      @change="onInstanceTypeChange"
    >
      <option value="">none</option>
      <option value="parallel">parallel</option>
      <option value="sequential">sequential</option>
    </select>
  </div>
</template>
<script setup lang="ts">
import { inject } from 'vue';

const target: any = inject('target');

function onInstanceTypeChange(e: any) {
  target.value.setProperties({
    multiInstanceType: e.target.value,
  });
}
</script>

bpmn-elements-adapter

bpmn-elements-adapter的代码可以算是这三部分内容里比较复杂和繁琐的,主要功能是导出时把LogicFlow的图数据转换为XML数据以及导入时将XML数据转换为LogicFlow的图数据,这里不详细的梳理里面每一部分都是怎么写的,我们主要看一下和之前的bpmn adapter相比有哪些变化,以及如何使用。 我们可以从源码里和插件REAMDE里看到,新的adapter新增了a对象类型的参数,里面有4个字段

type ExtraPropsType = {
  retainedAttrsFields?: string[];
  excludeFields?: excludeFieldsType;
  transformer?: TransformerType;
  mapping?: MappingType;
};

先假设我们有这样一个图数据

{
	node: [
		{
			type: 'sequenceFlow',
			pointsList: [{
				x: "1",
				y: "1"
			}],
			id: "1"
		}
	]
}

它会被转换成如下格式的数据

{
	sequenceFlow: [
		{
			id: '1',
			pointsList: [{
				x: "1",
				y: "1"
			}]
		}
	]
}

retainedAttrsFields: 直译过来应该是属性保留字段。例如,一般来说pointsList是一个数组,根据adapter的转换规则,如果没有设置retainedAttrsFields:['pointsList'],pointsList会被转换成

<sequenceFlow id="1">
	<pointsList>
		<0 x="1" y="1"/>
	</pointsList>
</sequenceFlow>

但是如果我们设置了retainedAttrsFields:['pointsList'],那么pointsList会被转换为属性,而不是节点,转换结果为

<sequenceFlow id="1" pointsList='[{"x":"1","y":"1"}]'/>

引用代码里的注释就是retainedAttrsFields数组里的字段当它的值是数组或是对象时不会被转化为节点而是属性。 需要注意的是,retainedAttrsFields内的值是path,例如properties里的events是一个对象,我们希望它被处理成属性,那么应该是retainedAttrsFields: ['pointsList','properties.events']

excludeFields: 直译过来就是不包含的字段,还是拿上面的pointsList为例,如果我们最终导出的XML里不需要pointsList的出现,我们可以设置excludeFields: ['pointsList'],这样pointsList就会被忽略,那么转换结果就是

<sequenceFlow id="1"/>

同样的,excludeFields里的值也是path

transformer: transformer应该可以说是新版本adapter相对来说最有用的部分,利用这个transformer我们可以把图数据里的节点转换成任何我们想要的样子,因为毕竟是把我们提前想好格式的字符串直接塞进XML里(逃 言归正传,我们结合文档中给出的例子看看transformer是什么样子的

transformer?: {
	[key: string]: {
		in?: (key: string, data: any) => any;
		out?: (data: any) => any;
	}
};

extraProps.transformer = {
    'bpmn:sequenceFlow': {
        out(data: any) {
            const { properties: { expressionType, condition } } = data;
            if (condition) {
                if (expressionType === 'cdata') {
                    return {
                        json:
                        `<bpmn:conditionExpression xsi:type="bpmn2:tFormalExpression"><![CDATA[\${${
                            condition
                        }}]]></bpmn:conditionExpression>`,
                    };
                }
                return {
                    json: `<bpmn:conditionExpression xsi:type="bpmn2:tFormalExpression">${condition}</bpmn:conditionExpression>`,
                };
            }
            return {
                json: '',
            };
        },
    },
    'bpmn:conditionExpression': {
        in(_key: string, data: any) {
            let condition = '';
            let expressionType = '';
            if (data['#cdata-section']) {
                expressionType = 'cdata';
                condition = /^\$\{(.*)\}$/g.exec(data['#cdata-section'])?.[1] || '';
            } else if (data['#text']) {
                expressionType = 'normal';
                condition = data['#text'];
            }
            return {
                '-condition': condition,
                '-expressionType': expressionType,
            };
        },
    },
}

在对bpmn:sequenceFlow节点进行导出时,节点数据被初步转换后是这样的

{
	'bpmn:sequenceFlow': [
		id: 'xxx',
		properties: {
			expressionType: 'cdata',
			condition: 'x === 1'
		}
	]
}

被初步转换后的数据格式才是adapter是实际做转换时会用到的。adapter内部会通过transformer[key].out()去获取和执行transformer里的方法,transformer[key].out()执行返回一个对象,对象里的字段都会被拷贝进当前处理的对象里,例如这里处理bpmn:sequenceFlow后其数据为

{
	'bpmn:sequenceFlow': [
		{
			id: 'xxx',
			properties: {
				expressionType: 'cdata',
				condition: 'x === 1'
			},
			json: "<bpmn:conditionExpression xsi:type='bpmn2:tFormalExpression'><![CDATA[\${x === 1}]]></bpmn:conditionExpression>"
		}
	]
}

这里的json字段里的内容最终会被拼入xml里,大概是这个意思

<bpmn:sequenceFlow id='xxx'>
	{{bpmn:sequenceFlow['json']}
</bpmn:sequenceFlow>
// 即
<bpmn:sequenceFlow id='xxx'>
	<bpmn:conditionExpression xsi:type='bpmn2:tFormalExpression'><![CDATA[${x === 1}]]></bpmn:conditionExpression>
</bpmn:sequenceFlow>

代码内是直接通过obj['json']去获取里面的字符串内容并进行拼接的,所以如果想用这样的方式处理节点的转换,json字段是必须被返回的。 在进行导入时

<bpmn:sequenceFlow id='xxx'>
	<bpmn:conditionExpression xsi:type='bpmn2:tFormalExpression'><![CDATA[${x === 1}]]></bpmn:conditionExpression>
</bpmn:sequenceFlow>

会被先处理成

{
	'bpmn:sequenceFlow': {
		id: 'xxx',
		"bpmn:conditionExpression": {
			'xsi:type': 'bpmn2:tFormalExpression',
			'#cdata-section': '${x === 1}'
		}
	}
}

由于没有bpmn:conditionExpression节点,所以需要把bpmn:conditionExpression上的相关数据放入其父元素(bpmn:sequenceFlow)的properties里,也就是说会调用transformer['bpmn:conditionExpression'].in,将函数执行返回对象中的字段加入到bpmn:sequenceFlow.properties,按照上面的transformer,导入数据会被最终处理成

{
	'bpmn:sequenceFlow': {
		id: 'xxx',
		properties: {
			condition: 'x === 1',
			expressionType: 'cdata'
		}
	}
}

mapping:直译过来就是映射,文档中没有说明是什么的映射,但是我们可以通过源码看到

nodes.forEach((node) => {
    if (other?.mapping?.in) {
      const mapping = other?.mapping?.in;
      const { type } = node;
      if (mapping[type]) {
        node.type = mapping[type];
      }
    }
    ignoreFields(node, excludeFieldsSet.in, '');
  });

其实指的是节点中属性名的映射,例如如果我们有这样一个数据

{
	'bpmn:sequenceFlow': {
		id: '123',
	  properties: {
		  expression: 'x !== 1'
	  }
	}
}

那么在没有其他配置项干预的情况下(不配置excludeFileds、transformer等),我们需要数据在导出时被转换为

<bpmn:sequenceFlow id='123' condition='x !== 1'/>

就需要配置

mapping: {
	out: {
		'expression': 'condition'
	}
}

导入时同理。 总结一下retainedAttrsFields,excludeFields,transformer,mapping配置执行的顺序,在导入时mapping(属性名映射) -> excludeFields(忽略某些属性) -> transformer(转换),导出时retainedAttrsFields(转换为属性) -> mapping(属性名映射) -> excludeFields(忽略某些属性) -> transformer(转换)

总结(套盾,个人向!)

bpmn-elements 和数据面板的实现比较合理,使用也相对方便,但是bpmn-elements-adapter的实现个人认为还是有可以值得改进和商讨的地方,以目前的实现来说,用肯定是可以用的,但是配置项看起来会有点冗余,如果细心一点可以从LogicFlow的更新日志和文档中发现,在1.2.5版本的时候为了解决一些问题就增加了retainedAttrsFields字段,其用法还是合理的;而excludeFields,我猜测是为了解决有时properties中或者属性里有一些不需要被导出,或者导出之后的XML数据不能被其他工具正常解析的问题,用法也尚可;再说transformer,除开借助transformer以外,还有一种方式应该可以让数据按我们的预期导出,假如我们有这样的图数据

nodes: [
	{
		type: 'bpmn:startEvent',
		id: '1',
		'bpmn:timerEventDefinition': {
			id: '1-1',
			'bpmn:timeCycle': {
				'xsi:type': 'bpmn:tFormalExpression',
				'#text': 'myCycle'
			}
		}
	}
]

初步转后变成:

{
	'bpmn:startEvent': {
		id: '1',
		'bpmn:timerEventDefinition': {
			id: '1-1',
			'bpmn:timeCycle': {
				'xsi:type': 'bpmn:tFormalExpression',
				'#text': 'myCycle'
			}
		}
	}
}

这时候,我们不需要做额外的处理,用旧版本的adapter就已经可以正常转换了,但是这种方式有个大问题上,如果被导出的节点结构复杂,属性嵌套会很深,不如前一种方式扁平,而且这种方式需要我们每次修改值的时候都调用setAttributes,或者通过别的封装过的方法去做值的修改,所以我们可以根据需要选择使用最方便的一种,这里暂时只想到了两种,如果还有别的更好的方法大家可以提出来,让官方修改修改(坏笑;最后是mapping字段,不知道实际开发中会不会有人使用这个字段,我们自定义节点的时候可以一开始就规范和确定好我们的节点名、属性名,不进行二次更改,导入导出的时候再做名称映射反而可能导致一些不确定性,所以合理性还有待商榷。

最后

这篇文章是笔者阅读LogicFlow的BPMN相关插件源码后的一些关于其设计和使用的理解和总结,内容的侧重点根据自己想法进行了调整,有些地方可能写的不清楚,还望大家谅解,有什么错误也希望大家可以及时指正~