In the previous article, we manually built a process object and simply printed and executed it. The way to build a process object is not very friendly. In order to build process objects more conveniently, we adopt a new method, that is, parse the process definition files mentioned in the basics and convert them into process models.
Here is a sample file to parse:
src/test/resources/leave.json
{<!-- --> "name": "leave", "displayName": "Leave", "instanceUrl": "leaveForm", "nodes": [ {<!-- --> "id": "start", "type": "snaker:start", "x": 340, "y": 160, "properties": {<!-- --> "width": "120", "height": "80" }, "text": {<!-- --> "x": 340, "y": 200, "value": "start" } }, {<!-- --> "id": "apply", "type": "snaker:task", "x": 520, "y": 160, "properties": {<!-- --> "assignee": "approve. operator", "taskType": "Major", "performType": "ANY", "autoExecute": "N", "width": "120", "height": "80", "field": {<!-- --> "userKey": "1" } }, "text": {<!-- --> "x": 520, "y": 160, "value": "Leave Request" } }, {<!-- --> "id": "approveDept", "type": "snaker:task", "x": 740, "y": 160, "properties": {<!-- --> "assignmentHandler": "com.mldong.config.FlowAssignmentHandler", "taskType": "Major", "performType": "ANY", "autoExecute": "N", "width": "120", "height": "80" }, "text": {<!-- --> "x": 740, "y": 160, "value": "Department Leader Approval" } }, {<!-- --> "id": "end", "type": "snaker:end", "x": 980, "y": 160, "properties": {<!-- --> "width": "120", "height": "80" }, "text": {<!-- --> "x": 980, "y": 200, "value": "End" } } ], "edges": [ {<!-- --> "id": "t1", "type": "snaker:transition", "sourceNodeId": "start", "targetNodeId": "apply", "startPoint": {<!-- --> "x": 358, "y": 160 }, "endPoint": {<!-- --> "x": 460, "y": 160 }, "properties": {<!-- --> "height": 80, "width": 120 }, "pointsList": [ {<!-- --> "x": 358, "y": 160 }, {<!-- --> "x": 460, "y": 160 } ] }, {<!-- --> "id": "t2", "type": "snaker:transition", "sourceNodeId": "apply", "targetNodeId": "approveDept", "startPoint": {<!-- --> "x": 580, "y": 160 }, "endPoint": {<!-- --> "x": 680, "y": 160 }, "properties": {<!-- --> "height": 80, "width": 120 }, "pointsList": [ {<!-- --> "x": 580, "y": 160 }, {<!-- --> "x": 680, "y": 160 } ] }, {<!-- --> "id": "t3", "type": "snaker:transition", "sourceNodeId": "approveDept", "targetNodeId": "end", "startPoint": {<!-- --> "x": 800, "y": 160 }, "endPoint": {<!-- --> "x": 962, "y": 160 }, "properties": {<!-- --> "height": 80, "width": 120 }, "pointsList": [ {<!-- --> "x": 800, "y": 160 }, {<!-- --> "x": 830, "y": 160 }, {<!-- --> "x": 830, "y": 160 }, {<!-- --> "x": 932, "y": 160 }, {<!-- --> "x": 932, "y": 160 }, {<!-- --> "x": 962, "y": 160 } ] } ] }
Class Diagram
Flowchart
Code Implementation
model/logicflow/LfPoint.java
package com.mldong.flow.engine.model.logicflow; import lombok.Data; import java.io.Serializable; /** * * logicFlow coordinates * @author mldong * @date 2023/4/26 */ @Data public class LfPoint implements Serializable {<!-- --> private int x; // x-axis coordinates private int y; // y-axis coordinates }
LogicFlow model object
model/logicflow/LfNode.java
package com.mldong.flow.engine.model.logicflow; import cn.hutool.core.lang.Dict; import lombok.Data; import java.io.Serializable; /** * * logicFlow node * @author mldong * @date 2023/4/26 */ @Data public class LfNode implements Serializable {<!-- --> private String id; // node unique id private String type; // node type private int x; // x-axis coordinates of node center point private int y; // node center point y-axis coordinates Dict properties; // node properties Dict text; // node text }
model/logicflow/LfEdge.java
package com.mldong.flow.engine.model.logicflow; import cn.hutool.core.lang.Dict; import lombok.Data; import java.io.Serializable; import java.util.List; /** * * LogicFlow side * @author mldong * @date 2022/6/12 */ @Data public class LfEdge implements Serializable {<!-- --> private String id; // the unique id of the edge private String type; // edge type private String sourceNodeId; // source node id private String targetNodeId; // target node id private Dict properties; // edge properties private Dict text; // edge text private LfPoint startPoint; // edge start point coordinates private LfPoint endPoint; // edge end point coordinates private List<LfPoint> pointsList; // collection of all points on the edge }
model/logicflow/LfModel.java
package com.mldong.flow.engine.model.logicflow; import com.mldong.flow.engine.model.BaseModel; import lombok.Data; import java.util.List; /** * * logicFlow model * @author mldong * @date 2023/4/26 */ @Data public class LfModel extends BaseModel {<!-- --> private String type; // Process definition classification private String expireTime;//expiration time (constant or variable) private String instanceUrl; // The url to start the instance, after the front and back ends are separated, it is defined as the route name or route address private String instanceNoClass; // When the process is started, the serial number generation class of the process instance private List<LfNode> nodes; // node collection private List<LfEdge> edges; // collection of edges }
Analysis class
parser/NodeParser.java
package com.mldong.flow.engine.parser; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.logicflow.LfEdge; import com.mldong.flow.engine.model.logicflow.LfNode; import java.util.List; /** * * Node resolution interface * @author mldong * @date 2023/4/26 */ public interface NodeParser {<!-- --> String NODE_NAME_PREFIX="snaker:"; // node name prefix String TEXT_VALUE_KEY = "value"; // text value String WIDTH_KEY = "width"; // node width String HEIGHT_KEY = "height"; // node height String PRE_INTERCEPTORS_KEY = "preInterceptors"; // pre-interceptors String POST_INTERCEPTORS_KEY = "postInterceptors"; // post interceptor String EXPR_KEY = "expr"; // expression key String HANDLE_CLASS_KEY = "handleClass"; // expression processing class String FORM_KEY = "form"; // form identifier String ASSIGNEE_KEY = "assignee"; // Participant String ASSIGNMENT_HANDLE_KEY = "assignmentHandler"; // Participant handling class String TASK_TYPE_KEY = "taskType"; // task type (host/co-organizer) String PERFORM_TYPE_KEY = "performType"; // Participation type (normal participation/countersigned participation) String REMINDER_TIME_KEY = "reminderTime"; // Reminder time String REMINDER_REPEAT_KEY = "reminderRepeat"; // Repeat reminder interval String EXPIRE_TIME_KEY = "expireTime"; // The expected task completion time variable key String AUTH_EXECUTE_KEY = "autoExecute"; // Whether to automatically execute Y/N upon expiration String CALLBACK_KEY = "callback"; // automatically execute the callback class String EXT_FIELD_KEY = "field"; // Custom extended attributes /** * Node attribute parsing method, which is parsed by the parsing class * @param lfNode LogicFlow node object * @param edges all edge objects */ void parse(LfNode lfNode, List<LfEdge> edges); /** * After the parsing is completed, provide the returned NodeModel object * @return node model */ NodeModel getModel(); }
parser/AbstractNodeParser.java
package com.mldong.flow.engine.parser; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.convert.Convert; import cn.hutool.core.lang.Dict; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.TransitionModel; import com.mldong.flow.engine.model.logicflow.LfEdge; import com.mldong.flow.engine.model.logicflow.LfNode; import java.util.List; import java.util.stream.Collectors; /** * * General attribute parsing (basic attributes and edges) * @author mldong * @date 2023/4/26 */ public abstract class AbstractNodeParser implements NodeParser {<!-- --> // node model object protected NodeModel nodeModel; @Override public void parse(LfNode lfNode, List<LfEdge> edges) {<!-- --> nodeModel = newModel(); // parse basic information nodeModel.setName(lfNode.getId()); if(ObjectUtil.isNotNull(lfNode.getText())) {<!-- --> nodeModel.setDisplayName(lfNode.getText().getStr(TEXT_VALUE_KEY)); } Dict properties = lfNode. getProperties(); // parse the layout properties int x = lfNode. getX(); int y = lfNode. getY(); int w = Convert.toInt(properties.get(WIDTH_KEY),0); int h = Convert.toInt(properties.get(HEIGHT_KEY),0); nodeModel.setLayout(StrUtil.format("{},{},{},{}",x,y,w,h)); // parse interceptor nodeModel.setPreInterceptors(properties.getStr(PRE_INTERCEPTORS_KEY)); nodeModel.setPostInterceptors(properties.getStr(POST_INTERCEPTORS_KEY)); // parse the output edge List<LfEdge> nodeEdges = getEdgeBySourceNodeId(lfNode.getId(), edges); nodeEdges.forEach(edge->{<!-- --> TransitionModel transitionModel = new TransitionModel(); transitionModel.setName(edge.getId()); transitionModel.setTo(edge.getTargetNodeId()); transitionModel.setSource(nodeModel); transitionModel.setExpr(edge.getProperties().getStr(EXPR_KEY)); if(CollectionUtil.isNotEmpty(edge.getPointsList())) {<!-- --> //x1,y1;x2,y2;x3,y3... transitionModel.setG(edge.getPointsList().stream().map(point->{<!-- --> return point. getX() + "," + point. getY(); }).collect(Collectors.joining(";"))); } else {<!-- --> if(ObjectUtil.isNotNull(edge.getStartPoint()) & amp; & amp; ObjectUtil.isNotNull(edge.getEndPoint())) {<!-- --> int startPointX = edge.getStartPoint().getX(); int startPointY = edge.getStartPoint().getY(); int endPointX = edge. getEndPoint(). getX(); int endPointY = edge. getEndPoint(). getY(); transitionModel.setG(StrUtil.format("{},{};{},{}", startPointX, startPointY, endPointX, endPointY)); } } nodeModel.getOutputs().add(transitionModel); }); // call subclass specific parsing method parseNode(lfNode); } /** * Subclasses implement this class to complete specific parsing * @param lfNode */ public abstract void parseNode(LfNode lfNode); /** * Create node model objects by subclasses respectively * @return */ public abstract NodeModel newModel(); @Override public NodeModel getModel() {<!-- --> return nodeModel; } /** * Get node input * @param targetNodeId target node id * @param edges * @return */ private List<LfEdge> getEdgeByTargetNodeId(String targetNodeId,List<LfEdge> edges) {<!-- --> return edges.stream().filter(edge->{<!-- --> return edge.getTargetNodeId().equals(targetNodeId); }).collect(Collectors.toList()); } /** * Get node output * @param sourceNodeId source node id * @param edges * @return */ private List<LfEdge> getEdgeBySourceNodeId(String sourceNodeId,List<LfEdge> edges) {<!-- --> return edges.stream().filter(edge->{<!-- --> return edge.getSourceNodeId().equals(sourceNodeId); }).collect(Collectors.toList()); } }
parser/impl/StartParser.java
package com.mldong.flow.engine.parser.impl; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.StartModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * Start node parsing class * @author mldong * @date 2023/4/26 */ public class StartParser extends AbstractNodeParser {<!-- --> @Override public void parseNode(LfNode lfNode) {<!-- --> } @Override public NodeModel newModel() {<!-- --> return new StartModel(); } }
parser/impl/EndParser.java
package com.mldong.flow.engine.parser.impl; import com.mldong.flow.engine.model.EndModel; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * End node parsing class * @author mldong * @date 2023/4/26 */ public class EndParser extends AbstractNodeParser {<!-- --> @Override public void parseNode(LfNode lfNode) {<!-- --> } @Override public NodeModel newModel() {<!-- --> return new EndModel(); } }
parser/impl/TaskParser.java
package com.mldong.flow.engine.parser.impl; import cn.hutool.core.convert.Convert; import cn.hutool.core.lang.Dict; import com.mldong.flow.engine.enums.TaskPerformTypeEnum; import com.mldong.flow.engine.enums.TaskTypeEnum; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.TaskModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * Task node parsing class * @author mldong * @date 2023/4/26 */ public class TaskParser extends AbstractNodeParser {<!-- --> /** * Parse the unique attributes of task nodes * @param lfNode */ @Override public void parseNode(LfNode lfNode) {<!-- --> TaskModel taskModel = (TaskModel) nodeModel; Dict properties = lfNode. getProperties(); taskModel.setForm(properties.getStr(FORM_KEY)); taskModel.setAssignee(properties.getStr(ASSIGNEE_KEY)); taskModel.setAssignmentHandler(properties.getStr(ASSIGNMENT_HANDLE_KEY)); taskModel.setTaskType(TaskTypeEnum.codeOf(properties.getInt(TASK_TYPE_KEY))); taskModel.setPerformType(TaskPerformTypeEnum.codeOf(properties.getInt(PERFORM_TYPE_KEY))); taskModel.setReminderTime(properties.getStr(REMINDER_TIME_KEY)); taskModel.setReminderRepeat(properties.getStr(REMINDER_REPEAT_KEY)); taskModel.setExpireTime(properties.getStr(EXPIRE_TIME_KEY)); taskModel.setAutoExecute(properties.getStr(AUTH_EXECUTE_KEY)); taskModel.setCallback(properties.getStr(CALLBACK_KEY)); // custom extension properties Object field = properties. get(EXT_FIELD_KEY); if(field!=null) {<!-- --> taskModel.setExt(Convert.convert(Dict.class, field)); } } @Override public NodeModel newModel() {<!-- --> return new TaskModel(); } }
parser/impl/ForkParser.java
package com.mldong.flow.engine.parser.impl; import com.mldong.flow.engine.model.ForkModel; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * Branch node parsing class * @author mldong * @date 2023/4/26 */ public class ForkParser extends AbstractNodeParser {<!-- --> @Override public void parseNode(LfNode lfNode) {<!-- --> } @Override public NodeModel newModel() {<!-- --> return new ForkModel(); } }
parser/impl/JoinParser.java
package com.mldong.flow.engine.parser.impl; import com.mldong.flow.engine.model.JoinModel; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * merge node parser * @author mldong * @date 2023/4/26 */ public class JoinParser extends AbstractNodeParser {<!-- --> @Override public void parseNode(LfNode lfNode) {<!-- --> } @Override public NodeModel newModel() {<!-- --> return new JoinModel(); } }
parser/impl/DecisionParser.java
package com.mldong.flow.engine.parser.impl; import cn.hutool.core.lang.Dict; import com.mldong.flow.engine.model.DecisionModel; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.logicflow.LfNode; import com.mldong.flow.engine.parser.AbstractNodeParser; /** * * Decision node parsing class * @author mldong * @date 2023/4/26 */ public class DecisionParser extends AbstractNodeParser {<!-- --> /** * Parse the unique attributes of decision nodes * @param lfNode */ @Override public void parseNode(LfNode lfNode) {<!-- --> DecisionModel decisionModel = (DecisionModel) nodeModel; Dict properties = lfNode. getProperties(); decisionModel.setExpr(properties.getStr(EXPR_KEY)); decisionModel.setHandleClass(properties.getStr(HANDLE_CLASS_KEY)); } @Override public NodeModel newModel() {<!-- --> return new DecisionModel(); } }
Service context related class
Context.java
package com.mldong.flow.engine; import java.util.List; /** * * Service context interface, similar to spring's ioc * @author mldong * @date 2023/4/26 */ public interface Context {<!-- --> /** * Register with the service factory according to the service name and instance * @param name service name * @param object service instance */ void put(String name, Object object); /** * Register with the service factory according to the service name and type * @param name service name * @param clazz type */ void put(String name, Class<?> clazz); /** * Determine whether the given service name exists * @param name service name * @return */ boolean exist(String name); /** * Find a service instance of a given type * @param clazz type * @return */ <T> T find(Class<T> clazz); /** * Find all service instances of a given type * @param clazz type * @return */ <T> List<T> findList(Class<T> clazz); /** * Find a service instance based on a given service name and type * @param name service name * @param clazz type * @return */ <T> T findByName(String name, Class<T> clazz); }
impl/SimpleContext.java
package com.mldong.flow.engine.impl; import cn.hutool.core.lang.Dict; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ReflectUtil; import com.mldong.flow.engine.Context; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * * Simple context discovery implementation class * @author mldong * @date 2023/4/26 */ public class SimpleContext implements Context {<!-- --> private Dict dict = Dict. create(); @Override public void put(String name, Object object) {<!-- --> dict. put(name, object); } @Override public void put(String name, Class<?> clazz) {<!-- --> dict.put(name, ReflectUtil.newInstance(clazz)); } @Override public boolean exist(String name) {<!-- --> return ObjectUtil.isNotNull(dict.getObj(name)); } @Override public <T> T find(Class<T> clazz) {<!-- --> for (Map.Entry<String, Object> entry : dict.entrySet()) {<!-- --> if (clazz. isInstance(entry. getValue())) {<!-- --> return clazz.cast(entry.getValue()); } } return null; } @Override public <T> List<T> findList(Class<T> clazz) {<!-- --> List<T> res = new ArrayList<>(); for (Map.Entry<String, Object> entry : dict.entrySet()) {<!-- --> if (clazz. isInstance(entry. getValue())) {<!-- --> res.add(clazz.cast(entry.getValue())); } } return res; } @Override public <T> T findByName(String name, Class<T> clazz) {<!-- --> for (Map.Entry<String, Object> entry : dict.entrySet()) {<!-- --> if (entry.getKey().equals(name) & amp; & amp; clazz.isInstance(entry.getValue())) {<!-- --> return clazz.cast(entry.getValue()); } } return null; } }
core/ServiceContext.java
package com.mldong.flow.engine.core; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ReflectUtil; import com.mldong.flow.engine.Context; import java.util.List; /** * * Singleton service context * @author mldong * @date 2022/6/12 */ public class ServiceContext {<!-- --> private static Context context; public static void setContext(Context context) {<!-- --> ServiceContext.context = context; } public static void put(String name, Object object) {<!-- --> Assert.notNull(context,"Unregistered service context"); context. put(name, object); } public static void put(String name, Class<?> clazz) {<!-- --> Assert.notNull(context,"Unregistered service context"); context.put(name, ReflectUtil.newInstance(clazz)); } public static boolean exist(String name) {<!-- --> Assert.notNull(context,"Unregistered service context"); return context.exist(name); } public static <T> T find(Class<T> clazz) {<!-- --> Assert.notNull(context,"Unregistered service context"); return context. find(clazz); } public static <T> List<T> findList(Class<T> clazz) {<!-- --> Assert.notNull(context,"Unregistered service context"); return context. findList(clazz); } public static <T> T findByName(String name, Class<T> clazz) {<!-- --> Assert.notNull(context,"Unregistered service context"); return context.findByName(name, clazz); } }
Analysis entry class
parser/ModelParser.java
package com.mldong.flow.engine.parser; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.json.JSONUtil; import com.mldong.flow.engine.core.ServiceContext; import com.mldong.flow.engine.model.NodeModel; import com.mldong.flow.engine.model.ProcessModel; import com.mldong.flow.engine.model.TaskModel; import com.mldong.flow.engine.model.TransitionModel; import com.mldong.flow.engine.model.logicflow.LfEdge; import com.mldong.flow.engine.model.logicflow.LfModel; import com.mldong.flow.engine.model.logicflow.LfNode; import java.io.ByteArrayInputStream; import java.util.List; public class ModelParser {<!-- --> private ModelParser(){<!-- -->} /** * Parse the json definition file into a process model object * @param bytes * @return */ public static ProcessModel parse(byte [] bytes) {<!-- --> String json = IoUtil. readUtf8(new ByteArrayInputStream(bytes)); LfModel lfModel = JSONUtil.parse(json).toBean(LfModel.class); ProcessModel processModel = new ProcessModel(); List<LfNode> nodes = lfModel. getNodes(); List<LfEdge> edges = lfModel. getEdges(); if(CollectionUtil.isEmpty(nodes) || CollectionUtil.isEmpty(edges) ) {<!-- --> return processModel; } // Process definition basic information processModel.setName(lfModel.getName()); processModel.setDisplayName(lfModel.getDisplayName()); processModel.setType(lfModel.getType()); processModel.setInstanceUrl(lfModel.getInstanceUrl()); processModel.setInstanceNoClass(lfModel.getInstanceNoClass()); // process node information nodes.forEach(node->{<!-- --> String type = node.getType().replace(NodeParser.NODE_NAME_PREFIX,""); NodeParser nodeParser = ServiceContext.findByName(type,NodeParser.class); if(nodeParser!=null) {<!-- --> nodeParser. parse(node, edges); NodeModel nodeModel = nodeParser. getModel(); processModel.getNodes().add(nodeParser.getModel()); if (nodeModel instanceof TaskModel) {<!-- --> processModel.getTasks().add((TaskModel) nodeModel); } } }); // Loop the node model, construct the source and target of the input edge and output edge for(NodeModel node : processModel. getNodes()) {<!-- --> for(TransitionModel transition : node.getOutputs()) {<!-- --> String to = transition. getTo(); for(NodeModel node2 : processModel. getNodes()) {<!-- --> if(to.equalsIgnoreCase(node2.getName())) {<!-- --> node2.getInputs().add(transition); transition.setTarget(node2); } } } } return processModel; } }
Configuration class
cfg/Configuration.java
package com.mldong.flow.engine.cfg; import com.mldong.flow.engine.Context; import com.mldong.flow.engine.core.ServiceContext; import com.mldong.flow.engine.impl.SimpleContext; import com.mldong.flow.engine.parser.impl.*; public class Configuration {<!-- --> public Configuration() {<!-- --> this(new SimpleContext()); } public Configuration(Context context) {<!-- --> ServiceContext.setContext(context); ServiceContext.put("decision", DecisionParser.class); ServiceContext.put("end", EndParser.class); ServiceContext.put("fork", ForkParser.class); ServiceContext.put("join", JoinParser.class); ServiceContext.put("start", StartParser.class); ServiceContext. put("task", TaskParser. class); } }
Unit test class
ModelParserTest.java
package com.mldong.flow; import cn.hutool.core.io.IoUtil; import cn.hutool.core.lang.Dict; import com.mldong.flow.engine.cfg.Configuration; import com.mldong.flow.engine.core.Execution; import com.mldong.flow.engine.model.ProcessModel; import com.mldong.flow.engine.parser.ModelParser; import org.junit.Test; /** * * Model parsing unit test * @author mldong * @date 2023/4/26 */ public class ModelParserTest {<!-- --> @Test public void parseTest() {<!-- --> new Configuration(); ProcessModel processModel = ModelParser. parse(IoUtil. readBytes(this. getClass(). getResourceAsStream("/leave. json"))); Execution execution = new Execution(); execution.setArgs(Dict.create()); processModel.getStart().execute(execution); } }
Run result
model:StartModel,name:start,displayName:start,time:2023-04-26 21:32:40 model:TaskModel,name:apply,displayName:leave application,time:2023-04-26 21:32:41 model:TaskModel,name:approveDept,displayName:Department Leader Approval,time:2023-04-26 21:32:42 model:EndModel,name:end,displayName:end,time:2023-04-26 21:32:42
Join an organization
Please open in WeChat:
“Lidong and His Friends”
Related source code
mldong-flow-demo-03
Process Designer
online experience