得物技术

可视化流量录制规则探索和实践|得物技术

Image

目录

一、前言

二、流量规则项目简介

三、配置可视化(基于ReactFlow引擎)

四、配置智能化

    1. 中心化工具栏

    2. 快速视图入口

    3. 本地缓存策略

五、组织数据驱动视图

    1. ReactFlow引擎如何开始工作

    2. 流量规则工具如何规划数据

    3. 自研数据->视图更新引擎

六、其他技术细节实践

    1. 根据屏幕尺寸实时调整视图窗口

    2. 区分条件和断言的背景区域

    3. 小地图颜色区分条件、断言

七、总结

一

前言

流量回放平台是将生产环境的流量录制下来在线下环境进行mock或不mock回放,但流量回放不能很好地细化接口规则,无法有效组织和验证业务接口内的预期表现,针对这一痛点,我们急需一个“构建智能规则验证流程,一键直达验证结果”的平台。前期经过深入调研,发现业内往往采用的是复杂的表单交互,从而也带来了以下几个问题:
  • 配置复杂,可读性不高;
  • 且/或(and/or)关系表达不够直观,用户往往需要很大的心智负担来学习,配置成本高;
  • 组织关系不够合理,无法实现单一页面完成所有接口流量规则配置。
旧的表现形式:
面对以上问题,我们需要解决以下问题:
  • 尽可能通过流程图直观地表达且/或(and/or)关系视图;
  • 通过单一页面完成规则项的添加管理工作;
  • 更好的引导,减少用户学习的心智负担。

二

流量规则项目简介

Image

可视化验证流量规则示例图

通过对业务接口的Respone、Request、子调用的拦截,提取JsonPath,如下图:
Image
JsonPath提取示例
通过预选操作符和期望值组合不同关系、断言语句,从而验证每个关系组合是否达到预期,如下图:
Image

验证流程图

三

配置可视化(基于ReactFlow引擎)

经过需求讨论和前期调研,前端视图需要把每个规则通过and/or关系符进行链接,最好有一个可用于统一编辑的面板,ReactFlow(用于构建基于节点的编辑器和交互式图表)基本满足我们的需求,需要通过ReactFlow实现如下功能:
  • 自定义关系节点、规则、组、连接线的样式;
  • 前后方插入组、规则;
  • 根据插入位置,实时计算并更新位置;
  • 组内新增规则,并实现组的宽度位置动态变化;
  • 节点的新增、编辑、转组、删除等操作。
可视化操作演示:

四

配置智能化

中心化工具栏

Image

中心化操作工具栏实例图

快速视图入口

Image

节点操作示例图

本地缓存策略

智能化本地储存策略,保证用户在未点击保存按钮时,离开或者刷新页面可以获得最近的数据,主要缓存策略如下:
  • 数据更新时,本地做一份Json的缓存数据;
  • 如果未操作视图,10s更新一次本地缓存;
  • 视图位置发生改变,本地缓存更新。

五

组织数据驱动视图

本篇开始,主要介绍基于ReactFlow一技术细节,例如:如何规划数据、绘制视图、更新视图、交互细节、关系语句和元数据的匹配等。通过本节,可以更好地理解ReactFlow工作方式以及如何组织复杂的数据交互!

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"    }}

自研数据->视图更新引擎

Image

数据->视图流程示意图

// 更新视图块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;};
推断节点和断言,生成不同的id前缀:
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);
// 提示msg if (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" ? ( <span className={`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 ? ( <Tooltip title={ <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> } > {/* */} <span className={`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> ) : ( <span className={`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" ? ( <span className={`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> ) : ( <span className={`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} /> ) : ( <> <span className={`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 ( <> { <Group left={child?.left} right={child?.right} child={child} /> } </> ); } else { return ( <> {<Group left={null} right={child?.right} child={child} />} </> ); } })}
<span className={`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} /> )} </> ); } })} </> );};
Image

最终关系节点示例

六

其他技术细节实践

根据屏幕尺寸实时调整视图窗口

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,      }));

区分条件和断言的背景区域

Image

实现上下背景颜色区分

style={{background:"linear-gradient(to bottom, white 0%, white 50%, #f1f5f9 50%, #e2e8f0 100%)}}

小地图颜色区分条件、断言

Image
const nodeMinColor = (node: any) => {    switch (node.data.params.type) {      case "condition":        return "#f97316";      case "assertion":        return "#7e22ce";      default:        return "#ddd";    }  };

七

总结

至此,这篇文章也接近尾声,当然项目中还有很多其他细节,鉴于篇幅原因不再一一介绍,从中我们学习到了ReactFlow引擎一些必要的技巧,以及我们如何组织一些复杂的数据逻辑,如何在复杂的交互场景下,学会用数据引擎的方式来驱动视图!当然我们也为实现更好的流量接口规则探索出一段新的道路!

往期回顾

1. 在得物的小程序生态实践
2. 客服测试流水线编排设计思路和准入准出应用|得物技术
3. 深入剖析时序Prophet模型:工作原理与源码解析|得物技术
4. 深入理解Babel - 项目管理工具lerna解析|得物技术
5. 星创编辑器在投放业务中的落地|得物技术

文 / Zuck

关注得物技术,每周一、三、五更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

“

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:

Image