可视化流量录制规则探索和实践|得物技术
目录
一、前言
二、流量规则项目简介
三、配置可视化(基于ReactFlow引擎)
四、配置智能化
1. 中心化工具栏
2. 快速视图入口
3. 本地缓存策略
五、组织数据驱动视图
1. ReactFlow引擎如何开始工作
2. 流量规则工具如何规划数据
3. 自研数据->视图更新引擎
六、其他技术细节实践
1. 根据屏幕尺寸实时调整视图窗口
2. 区分条件和断言的背景区域
3. 小地图颜色区分条件、断言
七、总结
一
前言
配置复杂,可读性不高; 且/或(and/or)关系表达不够直观,用户往往需要很大的心智负担来学习,配置成本高; 组织关系不够合理,无法实现单一页面完成所有接口流量规则配置。
尽可能通过流程图直观地表达且/或(and/or)关系视图; 通过单一页面完成规则项的添加管理工作; 更好的引导,减少用户学习的心智负担。
二
流量规则项目简介
可视化验证流量规则示例图
验证流程图
三
配置可视化(基于ReactFlow引擎)
自定义关系节点、规则、组、连接线的样式; 前后方插入组、规则; 根据插入位置,实时计算并更新位置; 组内新增规则,并实现组的宽度位置动态变化; 节点的新增、编辑、转组、删除等操作。
四
配置智能化
中心化工具栏
中心化操作工具栏实例图
快速视图入口
节点操作示例图
本地缓存策略
数据更新时,本地做一份Json的缓存数据; 如果未操作视图,10s更新一次本地缓存; 视图位置发生改变,本地缓存更新。
五
组织数据驱动视图
ReactFlow引擎如何开始工作
import React from 'react';import ReactFlow from 'reactflow';import 'reactflow/dist/style.css';// 初始化一些数据const initialNodes = [{ id: '1', position: { x: 0, y: 0 }, data: { label: '1' } },{ id: '2', position: { x: 0, y: 100 }, data: { label: '2' } }];// 这是一个连接线const initialEdges = [{ id: 'e1-2', source: '1', target: '2' }];// 渲染页面export default function App() {return (<div style={{ width: '100vw', height: '100vh' }}><ReactFlow nodes={initialNodes} edges={initialEdges} /></div>);}
流量规则工具如何规划数据
{"id": "lr-1", // id唯一标识"position": { // 位置信息"x": 100,"y": 100},"data": { // 详细数据"label": "条件","id": "lr-1","group": false, // 是否是组"width": 180,"height": 60,"infos": { // 额外的接口获得的 info"flowConditionItemId": 48,"smallType": 1,"expectValueType": "int","expectValue": "49","name": "$.[0].body.condition.projectId equals 49","projectId": 49,"flowRuleId": 32,"content": 48,"uri": "/console/v1/config/interface-list","sceneSampleId": 0,"smallName": "equals","ruleId": 148,"isEdit": false},"params": { // 需要再各个组件内传递的数据"type": "condition", // 类型, 区分条件、断言"isSelect": true, // 是否选中的状态"dependNodeId": "ln-2", // 是否有依赖的组id"isSelectNode": false}},"type": "ruleItemNode", // 渲染类型"parentId": null, // 是否有组的id"width": 180,"height": 65,"positionAbsolute": {"x": 100,"y": 100}}
{"id": "ln-2=>lr-1", // 连接线id"source": "ln-2", // 从那个块触发开始链接"target": "lr-1", // 结束块 id"animated": true, // 是否有动画"markerEnd": { // 连接线末尾样式"type": "arrowclosed","width": 20,"height": 20,"color": "#ef4444"},"type": "smoothstep","style": { // 注入一些额外的样式"strokeWidth": 1,"stroke": "#ef4444"}}
自研数据->视图更新引擎
数据->视图流程示意图
// 更新视图块const onNodesChange = useCallback((changes: any) => {setNodes((oldNodes) => applyNodeChanges(changes, oldNodes));},[setNodes]);// 更新连接线const onConnectHandle = (params: any) => {setEdges((oldEdges) => {return applyEdgeChanges(params, oldEdges);});};
// 规则数据export const initRuleData = (props: intProps) => {const { id, position, data, type, parentId } = props;return {id: id,position: position,data: data,type: type,parentId: parentId,};};// 节点数据export const initNodeData = (props: intProps) => {const { id, show, hidden, children, position, data, type, parentId } = props;return {id: id,hidden: hidden,position: position,parentId: parentId,data: data,type: type,children: children,};};// 组数据export const initGroup = (props: intProps) => {const { id, style, show, hidden, position, data, type } = props;return {id: id,show: show,style: style,hidden: hidden,position: position,data: data,type: type,};};// 连接线export const initEdgesData = (props: intProps) => {const {id,source,target,label,animated,markerEnd,style,sourceHandle,type,lineType,isGroup,} = props;const init_markerEnd = (type: any, label: string) => {return {type: MarkerType.ArrowClosed,width: 20,height: 20,color:type === "condition"? label === "and"? "#f97316": "#ef4444": label === "and"? "#7e22ce": "#a855f7",};};const init_style = (type: any, label: string) => {return {strokeWidth: 1,stroke:type === "condition"? label === "and"? "#f97316": "#ef4444": label === "and"? "#7e22ce": "#a855f7",};};return {id: id,source: source,target: target,label: label,animated: animated,markerEnd: isGroup? {}: markerEnd? markerEnd: init_markerEnd(lineType, label),type: type,style: isGroup ? {} : style ? style : init_style(lineType, label),sourceHandle: sourceHandle,};};
const createItemNode = (params:paramsType) => {return initNodeData({id: nodeId,hidden: hidden,position: position,parentId: parentId ? parentId : null,data: {label: `${pattern === "condition" ? "条件" : "断言"}节点`,id: nodeId,infos: {conditionVal: "and", // 默认条件值},group: parentId ? true : false,type: pattern,width: parentId ? constants.NODE_GROUP_WIGHT : constants.NODE_WIGHT,height: parentId ? constants.NODE_GROUP_HEIGHT : constants.NODE_HEIGHT,params: {type: pattern,target: [preItem?.id, nextItem?.id],source: nodeId,onSelectHandle: callback?.onSelectHandle,switchConditionHandle: callback?.switchConditionHandle, // 切换节点isSelect: false,},},type: "nodeItemNode",});};
// 计算新的x位置const createPositionX = (nodes: any[]) => {if (!nodes || !nodes.length) return 100;const lastItem = nodes.at(-1);let marginWidth =lastItem.type === "groupItemNode"? lastItem.width || constants.GROUP_WIGHT: constants.RULE_WIGHT;return lastItem?.position.x + marginWidth + constants.MARGIN_X;};// 组内计算x位置const createInsertGroupPositionX = (nodes: any[]) => {if (!nodes || !nodes.length) return 10;const lastItem = nodes.at(-1);let marginWidth = constants.GROUP_INSERT_RULE_WIGHT;return lastItem?.position.x + marginWidth + constants.GROUP_INSERT_MARGIN_X;};
const getPrefix = (type: string = "groupItemNode") => {if (parentId) {// 组内新增条件return pattern === "condition" ? `${parentId}-lr/-` : `${parentId}-rr/-`;} else {// 组外新增 组或者条件if (type === "groupItemNode") {return pattern === "condition" ? `lg-` : `rg-`;} else if (type === "nodeItemNode") {return pattern === "condition" ? `ln-` : `rn-`;} else {return pattern === "condition" ? `lr-` : `rr-`;}}};
const createEdges = (nodes: any[]) => {if (!nodes || !nodes.length) return [];let edgesAry: any = [];// 获得所有节点const nodeArray =nodes.filter((item: any) => item?.type === "nodeItemNode") || [];nodeArray.map((item: any) => {let targetArray = item?.data?.params?.target || [];targetArray.map((target: any) => {let edgeId = `${item?.data?.params?.source}=>${target}`;if (edgesAry.findIndex((ed: any) => ed.id === edgeId) <= -1) {const newEdgeItem = initEdgesData({id: `${item?.data?.params?.source}=>${target}`,source: item?.data?.params?.source,target: target,animated: true,lineType: item?.data?.type,isGroup: item.parentId ? true : false,type: "smoothstep",});edgesAry.push(newEdgeItem);}});});return edgesAry;};
const update = (params: paramsType) => {const {newNode, newEdges, parentId, type, newGroupItem, pattern = 'condition', msgType = null}updateHandle(newNode);addEdgesHandle(newEdges);//本地储存localInitHandle(newNode, newEdges);let expression = expressionHandle(newNode);// 整体校验回调validateAllHandle(expression);// 预览结果setExpression(expression);// 提示msgif (msgType) {switch (msgType) {case "insertGroup":message.success(`插入【组】成功`);break;case "insertRule":message.success(`插入成功`);break;case "deleteGroup":message.success(`删除【组】成功`);break;case "deleteRule":message.success(`删除成功`);break;case "changeGroup":message.success(`转组成功`);break;case "insertGroupAdd":message.success(`组内新增成功`);break;case "editRule":message.success(`编辑更新成功!`);break;case "none":break;}}// 添加动态视图窗口移动if (newGroupItem && !parentId) {setViewXY((p: any) => ({...p,y:pattern === "condition"? height <= 1000? -newGroupItem?.position?.y + 150: -newGroupItem?.position?.y + 300: height <= 1000? -newGroupItem?.position?.y + 700: -newGroupItem?.position?.y + 1000,x: -newGroupItem?.position?.x + 1000,zoom: height <= 1000 ? 0.8 : 0.9,}));}};
import { Divider, Tag, Tooltip } from "antd";export const Expression = (props: any) => {const { list, theme, matching, check } = props;const matchNode = matching?.id || "";const matchLeft = matching?.params?.target[0] || "";const matchRight = matching?.params?.target[1] || "";// 项目const clause = (value: any, matchId: any = false, path: any = null) => {if (!value) return "";let group_matchId = JSON.parse(JSON.stringify(matchId));if (matchId && matchId.includes("/-")) {const idLists = matchId.split("-");matchId = `${idLists[1]}-${idLists[2]}`;}return theme === "small" ? (<spanclassName={`font-[200] align-bottom text-[10px] text-gray-500 ${matchId === matchLeft ||matchId === matchRight ||group_matchId === matchLeft ||group_matchId === matchRight? "text-orange-600 font-bold underline decoration-1": ""}`}>{value}</span>) : !check ? (<Tooltiptitle={<div className="text-[12px] p-1">{/* <div><span className="text-gray-400">json-path: </span><span className="text-[#00c1c2]">{path}</span></div><Divider className="my-[5px] border-cool-gray-600" /> */}<div>{value}</div></div>}>{/* */}<spanclassName={`font-[200] cursor-pointer truncate max-w-[50px] inline-block align-bottom text-[10px] rounded-sm py-[1px] px-[3px] m-b-[3px] ${theme && theme === "light"? "bg-gray-300 text-[#000]": "bg-blue-950 text-gray-400 "}`}>{value}</span></Tooltip>) : (<spanclassName={`font-[200] inline-block align-bottom text-[10px] rounded-sm py-[1px] px-[3px] m-b-[3px] ${theme && theme === "light"? "bg-gray-200 text-[#000]": "bg-blue-950 text-gray-400 "}`}>{value}</span>);};// 连接符号const connector = (value: any,matchId: any = false,isGroup: any = false) => {if (!value) return "";return theme === "small" ? (<spanclassName={`mx-[4px] inline-block align-bottom text-[10px] ${matchId === matchNode? "text-orange-600 font-bold underline decoration-1" //font-bold underline decoration-1: "text-gray-400"} `}>{value}</span>) : (<spanclassName={`font-bold mx-[4px] inline-block align-top m-t-[1px] text-[12px] ${isGroup? "text-gray-500": value === "and"? "text-red-600": "text-yellow-500"}`}>{value}</span>);};// 组const Group = (props: any) => {const { left, right, child } = props;return (<>{left ? clause(left?.name, left?.itemId, left?.content) : ""}{child ? connector(child?.conditionVal, child?.itemId, true) : ""}{right ? clause(right?.name, right?.itemId, right?.content) : ""}</>);};//处理组的显示const GroupHandle = (props: any) => {const { children } = props;return (<>{!children || !children.length ? (<Group left={null} right={null} child={null} />) : (<><spanclassName={`text-gray-400 m-r-[4px] inline-block ${theme === "small" ? "align-bottom" : "-m-t-[6px] align-middle"}`}>(</span>{children?.map((child: any, childIndex: number) => {if (childIndex === 0) {return (<>{<Groupleft={child?.left}right={child?.right}child={child}/>}</>);} else {return (<>{<Group left={null} right={child?.right} child={child} />}</>);}})}<spanclassName={`text-gray-400 m-l-[4px] inline-block ${theme === "small" ? "align-bottom" : "-m-t-[6px] align-middle"}`}>)</span></>)}</>);};return (<>{list?.map((item: any, index: number) => {if (index === 0) {return (<>{!item?.left?.children?.length ? (clause(item?.left?.name,item?.left?.itemId,item?.left?.content)) : (<GroupHandle children={item?.left?.children} />)}{(item?.left?.name || item?.left?.children?.length) &&(item?.right?.name || item?.right?.children?.length)? connector(item?.conditionVal, item?.itemId): ""}{!item?.right?.children?.length ? (clause(item?.right?.name,item?.right?.itemId,item?.right?.content)) : (<GroupHandle children={item?.right?.children} />)}</>);} else {return (<>{item?.right?.name || item?.right?.children?.length? connector(item?.conditionVal, item?.itemId): ""}{!item?.right?.children?.length ? (clause(item?.right?.name, item?.right?.itemId)) : (<GroupHandle children={item?.right?.children} />)}</>);}})}</>);};
最终关系节点示例
六
其他技术细节实践
根据屏幕尺寸实时调整视图窗口
setViewXY((p: any) => ({...p,y:pattern === "condition"? height <= 1000? -newGroupItem?.position?.y + 150: -newGroupItem?.position?.y + 300: height <= 1000? -newGroupItem?.position?.y + 700: -newGroupItem?.position?.y + 1000,x: -newGroupItem?.position?.x + 1000,zoom: height <= 1000 ? 0.8 : 0.9,}));
区分条件和断言的背景区域
实现上下背景颜色区分
style={{background:"linear-gradient(to bottom, white 0%, white 50%, #f1f5f9 50%, #e2e8f0 100%)}}
小地图颜色区分条件、断言
const nodeMinColor = (node: any) => {switch (node.data.params.type) {case "condition":return "#f97316";case "assertion":return "#7e22ce";default:return "#ddd";}};
七
总结
往期回顾
1. 在得物的小程序生态实践
2. 客服测试流水线编排设计思路和准入准出应用|得物技术
3. 深入剖析时序Prophet模型:工作原理与源码解析|得物技术
4. 深入理解Babel - 项目管理工具lerna解析|得物技术
5. 星创编辑器在投放业务中的落地|得物技术
文 / Zuck
关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
“
扫码添加小助手微信
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: