ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

上位机流程编程DLL设计:状态机与六边形架构实践

上位机流程编程DLL设计:状态机与六边形架构实践 1. 为什么流程编程要做成DLL一个上位机老项目的复盘先讲点实在的。我接过一个设备改造项目原来的上位机逻辑全部写死在按钮的Click事件里按下“启动”就开始跑一段几百行的for循环中间掺杂着串口发送、延时等待、IO检测和各种MessageBox提示。设备型号一多每个型号的工艺顺序又不一样代码里全是ifmodel A型嵌套加一个新工艺就要在十几个地方打补丁。那时候我就意识到上位机最值钱的部分根本不是那几个窗体而是那套“按顺序执行动作、根据结果做判断”的流程逻辑。后来我做了一个决定把流程执行引擎单独抽出来编译成一个独立的DLL。上位机主程序只负责界面展示、参数录入和状态显示所有流程编排、设备动作调度、异常分支处理全部交给这个DLL去跑。这个思路带来的好处是立竿见影的——换界面框架不用动流程逻辑新增设备型号只需要新增一份流程配方主程序甚至可以在调试模式下像放电影一样逐条执行工序而不影响设备。1.1 流程编程DLL的职责边界很多新手有个误区觉得DLL就是把一堆工具函数打个包比如串口封装、数据库访问、日志记录放到一个类库里就算完事。这确实是一种DLL的用法但它不叫“流程编程”。流程编程DLL的核心是你要让一段工艺过程可以被“编程”。所谓编程不是写代码而是用数据驱动的方式把设备动作序列、判断条件、循环分支、延时等待、报警处理这些原本散落在代码里的东西变成一份可以自由组合的“流程配方”。我最终定的职责边界是这样的主程序负责人机交互、参数录入、实时曲线、报警弹窗、用户权限。流程DLL负责工序解析、顺序执行、条件判断、循环与跳转、超时处理、共享变量管理、向主程序汇报当前执行位置和状态。通过接口解耦DLL不依赖具体主程序也不依赖具体设备型号。这么一分主程序的代码量至少降了一半而且流程DLL可以被多个项目复用。同一套设备换一种工艺不再需要改代码重新编译只需要在配方文件里改流程顺序和参数这正好是“多功能”三个字的真正含义。1.2 我最终是怎么定义最小契约的第一版DLL我设计得很复杂又是继承又是多态又是插件化结果自己用起来都费劲。后来简化成一套非常精简的API核心就五个public interface IFlowHost { void NotifyState(string stepName, string state, string message); bool AskUser(string question); void Log(string message); }这五个接口足够撑起一个完整的流程编程系统。因为DLL内部不需要关心你界面长什么样它只需要把“当前要做什么、做成什么样”告诉你由主程序决定怎么显示。反过来DLL调用的设备动作也不是直接写死而是通过命令注册表查找。主程序把设备动作注册成一个个函数DLL按流程配方里的命令名去调用这就实现了“流程和设备的完全解耦”。这套设计说白了就四个字窄接口宽实现。外部看到的接口越窄内部越自由后面对接其他项目也越省事。2. 把流程编程的核心拆出来程序、工序与状态机流程编程要服务于真实的生产工艺所以我参照了PLC编程中的“步进”概念把整个流程拆成三层模型。这是整个DLL的灵魂理解了这个后面所有代码都好写。2.1 三层模型ProcProgram / ProcStep / ProcAction第一层是ProcProgram代表一条完整的工艺程序。比如“焊接一号线材”、“测试老化流程”它包含一组有序的工序节点以及流程级别的共享变量和超时设定。第二层是ProcStep代表一个工序节点。每个工序有一组前置条件、一个对外命令、一组后置动作以及下一步的跳转规则。工序之间通过转移条件连接转移条件可以是固定的“OK就往下走”也可以是变量表达式判断。第三层是ProcAction代表工序内部的具体动作。一个工序可以有多个动作按顺序执行比如先给气缸发送打开命令然后等待传感器反馈超时则跳转报警工序。这三个类用C#写出来大概是这样的public class ProcVariable { public string Name { get; set; } public string Value { get; set; } } public class ProcAction { public string Command { get; set; } // 命令名比如 SetCylinder public Dictionarystring, string Params { get; set; } // 参数表 public int TimeoutMs { get; set; } } public class ProcStep { public string Name { get; set; } public ListProcAction Actions { get; set; } public string GotoOnOk { get; set; } // 成功后的下一工序 public string GotoOnFail { get; set; } // 失败后的下一工序 public string GotoExpression { get; set; } // 可选自定义跳转表达式 } public class ProcProgram { public string ProgramName { get; set; } public string EntryStep { get; set; } public ListProcStep Steps { get; set; } public ListProcVariable Variables { get; set; } }注意这里每个字段都是字符串和字典而不是强类型的参数对象。为什么这么做因为流程配方通常是从文本文件、数据库或者远端下发的保持字符串形态可以做到“改配方不重新编译”。参数校验在运行时交给命令处理器去做DLL本身不做业务校验。2.2 状态机是这套DLL的发动机流程执行的本质就是一个状态机当前处于哪个工序执行完动作后根据结果跳转到哪个工序。状态机在这里反而比自由编排逻辑更可靠因为每个工序只有有限的出边不会出现流程失控跑飞的情况。我在DLL内部维护一个执行上下文public class FlowContext { public ProcProgram CurrentProgram { get; set; } public ProcStep CurrentStep { get; set; } public Dictionarystring, string SharedVariables { get; set; } public CancellationTokenSource CancelToken { get; set; } }每次循环取当前工序执行动作然后根据动作结果计算下一步。没有复杂的异步调度就是一个while循环加一个状态字段。千万不要一上来就上什么并发模型、async/await满天飞设备控制场景最怕的就是执行顺序和回调时序不确定。2.3 StepResult流程判断的真正核心动作执行完之后怎么决定跳转我定义了一个StepResult枚举public enum StepResult { Ok, // 正常完成走 GotoOnOk Fail, // 明确失败走 GotoOnFail Timeout, // 超时走 GotoOnFail 或专门的超时处理 Retry, // 需要重试当前工序 Abort, // 中断整个流程 WaitInput // 等待外部条件满足再继续 }判断的原则很简单命令处理器返回成功或失败执行引擎只根据这三个结果做跳转。但实际项目中“超时”最麻烦我特意把Timeout独立出来而不是归进Fail因为超时的处理策略往往和明确失败不一样。比如气缸到位传感器没信号可能只是慢了一点重试一次就好了但如果是真空压力不足重试一百次也没用必须走报警。从这三层模型和状态机的角度看流程编程DLL的骨架已经完全清晰了。3. 六边形架构在DLL内部怎么落地的做DLL最怕什么最怕DLL内部到处硬编码引用设备SDK、串口类、数据库连接。今天用的是海康相机明天换成巴斯勒就得拆DLL改代码今天走串口明天走TCP又得拆一遍。为了解决这个问题我在DLL内部采用了六边形架构的思路尽管是一个很小的DLL但这个结构带来的维护便利远超预期。3.1 从设备SDK里彻底解耦我不允许任何设备SDK的类型出现在流程DLL的公开方法签名里。比如你调用海康相机的SDK它的回调参数、图像类型、错误码都是海康特有的一旦这些类型出现在DLL接口中DLL就和海康绑死了。取而代之的是我把“拍一张图并检测”这个动作定义成一个通用命令public interface IDeviceAdapter { CommandResult ExecuteCommand(string command, Dictionarystring, string args); }主程序或者设备适配层去实现这个接口在实现内部再调用海康SDK或西门子PLC通信库。流程DLL只认识“GrabImage”这个字符串和“OK”这个结果完全不关心底层是USB摄像头还是GigE工业相机。3.2 三层划分核心、端口、适配器核心层Core流程模型、状态机引擎、变量解析、命令调度逻辑。这部分完全和外部世界无关建立了纯内存里的流程执行环境。端口层Ports声明接口比如IDeviceAdapter、ILogger、IUserNotify、IParameterReader。这些接口代表“外部世界需要向核心提供的服务”。适配器层Adapters主程序或外围模块实现这些接口把真实的串口、IO板卡、数据库、界面绑定进来。这么做的效果是我测试流程引擎的时候根本不需要接设备写一个模拟适配器返回固定响应就行。团队里另一个成员可以同时开发真实设备适配器两边互不阻塞。3.3 简单封装背后的调度时序思考有人会说这不就是依赖注入嘛搞这么复杂干嘛。确实本质上就是依赖注入但难的不是概念而是注入的时机和粒度。我在DLL里没有用任何IoC容器就是简单的构造函数传参public class FlowEngine { private readonly IDeviceAdapter _device; private readonly IFlowHost _host; private readonly ILogger _logger; public FlowEngine(IDeviceAdapter device, IFlowHost host, ILogger logger) { _device device; _host host; _logger logger; } }这样做的原因很简单上位机项目依赖关系本来就不复杂引入IoC容器反而增加心智负担和调试难度。手动构造稍微啰嗦但每个依赖都明明白白出问题了一眼就能看出来。4. 核心功能代码的实操写法架构说清楚了下面进入实操环节。这一节的内容是从我实际项目里抽取出来的比较有代表性的写法每一步都经过生产验证可以直接参考。4.1 配方解析从文本到可执行对象流程配方通常是JSON或者XML。我选了JSON因为可读性好、层级清晰、C#解析方便。一个工序文件长这样{ programName: 组装流程V2, entryStep: 上料, variables: [ { name: pressure, value: 0.8 }, { name: targetTemp, value: 120 } ], steps: [ { name: 上料, actions: [ { command: SetCylinder, params: { id: 1, position: open } } ], gotoOnOk: 压装, gotoOnFail: 报警 }, { name: 压装, actions: [ { command: SetPressure, params: { value: $pressure$, timeout: 3000 } } ], gotoOnOk: 保压检测, gotoOnFail: 报警 } ] }注意表达式里我用了$pressure$这种占位符解析的时候从共享变量表里取值替换。这套规则很简单但非常实用因为配方里经常用到传感器阈值、目标温度这类频繁调整的参数不用动流程文件本身。解析代码用System.Text.Json就够public static ProcProgram ParseProgram(string json) { var options new JsonSerializerOptions { PropertyNameCaseInsensitive true }; return JsonSerializer.DeserializeProcProgram(json, options); }4.2 命令循环和执行引擎执行引擎的核心是一个循环加一个状态转移。具体逻辑如下public async TaskStepResult Run(ProcProgram program, FlowContext context) { var currentStep program.Steps.First(s s.Name program.EntryStep); while (true) { context.CurrentStep currentStep; if (context.CancelToken.IsCancellationRequested) return StepResult.Abort; _host.NotifyState(currentStep.Name, Executing, ); var result await ExecuteActions(currentStep, context); // 根据结果决定跳转 if (result StepResult.Ok !string.IsNullOrEmpty(currentStep.GotoExpression)) { result EvaluateGotoExpression(currentStep.GotoExpression, context); } var nextName result switch { StepResult.Ok currentStep.GotoOnOk, StepResult.Fail currentStep.GotoOnFail, StepResult.Timeout currentStep.GotoOnFail, StepResult.Abort string.Empty, StepResult.Retry currentStep.Name, _ throw new InvalidOperationException(Unknown result) }; if (string.IsNullOrEmpty(nextName) || result StepResult.Abort) return result; currentStep program.Steps.First(s s.Name nextName); } } private async TaskStepResult ExecuteActions(ProcStep step, FlowContext context) { foreach (var action in step.Actions) { var resolvedParams ResolveParameters(action.Params, context.SharedVariables); var cmdResult await _device.ExecuteCommandAsync(action.Command, resolvedParams); if (!cmdResult.Success) return StepResult.Fail; } return StepResult.Ok; }这里有一个执行顺序上的细节我先把工序内所有动作都执行完再统一判断跳转。这在大多数流程里够用但如果某个工序内部的动作之间也需要条件分支那就要把Step拆成更细的ActionStep。我的建议是保持简单用多个工序代替工序内部的分支逻辑。4.3 共享变量与表达式计算共享变量是流程DLL里最容易做“简单但容易出错”的部分。最简单的实现是Dictionarystring, string但并发读写会出现脏数据。我用了线程安全的ConcurrentDictionary并且在执行引擎里定义了一个规则只有流程主循环可以写变量命令处理器只能读。这个约定能让变量竞争问题基本消失。变量值的替换用正则表达式实现private static string ResolveExpression(string input, Dictionarystring, string variables) { return Regex.Replace(input, \$(\w)\$, match { var key match.Groups[1].Value; return variables.TryGetValue(key, out var value) ? value : match.Value; }); }如果要支持更复杂的数学表达式比如温度换算、压力补偿可以引入NCalc库或CodeDom表达式编译。但我建议尽量少用因为配方文件里一旦出现复杂表达式调试难度会直线上升。能用一个变量名解决的问题不要写一个表达式。5. 一条流程从文本到跑通的完整路径光有引擎没有验证不算完成。我习惯用一套模拟设备环境来验证流程逻辑写一个模拟IDeviceAdapter针对不同的命令返回预设的结果然后跑完整条流程。这算是流程DLL最基础也最刚需的调试手段。5.1 准备一个最小可跑的流程配方{ programName: 模拟测试, entryStep: 启动, variables: [ { name: heatingTime, value: 2 } ], steps: [ { name: 启动, actions: [ { command: MockDelay, params: { ms: 100 } } ], gotoOnOk: 加热 }, { name: 加热, actions: [ { command: MockHeater, params: { temp: 100 } } ], gotoOnOk: 冷却 }, { name: 冷却, actions: [ { command: MockTimer, params: { ms: 300 } } ], gotoOnOk: 结束 }, { name: 结束, actions: [], gotoOnOk: } ] }模拟适配器里注意要区分不同命令的返回值。MockDelay就是真的等一下MockHeater校验一下temp参数是否被正确解析成100MockTimer验证超时路径。5.2 在WinForms上位机里挂载DLLWinForms本身没有特殊要求只要在项目里添加DLL引用然后构造设备适配器再创建FlowEngine实例var device new SimulatedDeviceAdapter(); var host new WinFormsFlowHost(this); // 实现IFlowHost接口在UI线程上显示状态 var logger new FileLogger(flow.log); var engine new FlowEngine(device, host, logger); var program FlowParser.ParseProgram(File.ReadAllText(recipe.json)); var context new FlowContext { SharedVariables new Dictionarystring, string() }; var result await engine.Run(program, context); if (result ! StepResult.Ok) { MessageBox.Show(流程异常结束 result); }WinFormsFlowHost里要注意IFlowHost接口可能从工作线程回调更新UI控件时必须Invoke到UI线程否则会抛出跨线程访问异常。这是新手上位机最容易踩的坑。5.3 跑通这条流程的观察细节跑通一条流程后我通常重点确认三件事工序顺序对不对每一步的进入和退出时间戳是否和预期匹配。参数解析对不对命令处理器收到的参数值是否经过变量替换成了真实数值。超时和失败路径对不对故意让某一步失败看流程是否按预期跳转到报警工序。这三件事全部通过之后这个流程DLL才算真正能用。不要一上来就接真实设备跑那样一旦出错你分不清是设备问题、通信问题还是流程逻辑问题排错成本会非常高。6. 调试、稳定性与避坑记录从第一个能跑的DLL到真正稳定上产线中间踩过的坑比想象中多。挑几个最有代表性的记录一下这些都是真实项目中花费不少时间才摸清楚的问题。6.1 深拷贝问题引用类型属性在流程复制时被共享流程模型定义成类之后就有一个继承自C#引用类型特性的隐患如果两份流程共享同一个ProcProgram实例修改一个流程的变量可能会影响另一个流程。解决办法是给ProcProgram实现深拷贝方法或者在反序列化每个流程实例时保证数据隔离public ProcProgram DeepClone() { var json JsonSerializer.Serialize(this); return JsonSerializer.DeserializeProcProgram(json); }这个方法虽然粗暴但胜在一行代码解决所有深拷贝问题。被坑过一次之后我凡是加载新流程实例都用DeepClone绝不共享。6.2 命令处理器不要过度设计第一版我试图给每个命令创建独立的处理器类还要实现一个命令注册表接口用反射自动发现命令处理器。结果项目一大命令越来越多每个命令的构造函数各不相同注册表变得极其复杂。后来老老实实改成一个大方法public CommandResult ExecuteCommand(string command, Dictionarystring, string args) { switch (command) { case SetCylinder: return SetCylinder(args); case SetPressure: return SetPressure(args); // ... 其他命令 default: return new CommandResult(false, $未知命令: {command}); } }简单、直观、调试方便。需要增加命令时在大方法里加一个分支就行。对于几百个命令级别的规模这个设计完全够用而且不会有反射带来的性能开销和调试复杂度。6.3 共享变量并发访问的可见性问题另一个很容易被忽视的坑是变量字典的线程可见性。虽然ConcurrentDictionary保证线程安全但如果你在流程中同时启动一个后台线程去轮询传感器并更新变量主流程读取变量时可能会读到旧的值。解决办法是变量字典的引用字段声明为volatile或者直接把它传给CommandExecutor时每次都从上下文里取最新引用更新变量时用TryUpdate或AddOrUpdate避免对同一个key并发写最简单的方案是后台线程不直接写主流程的变量字典而是把结果放进一个队列由主流程在合适的节点主动消费。这样数据流是单向的能有效避免并发冲突。6.4 超时命令要放到Task层面命令执行如果被卡住会让整个流程死等。所以我在设计IDeviceAdapter时执行命令统一返回Task并在引擎里用Task.WhenAny配合超时来做var execTask _device.ExecuteCommandAsync(command, args); var completed await Task.WhenAny(execTask, Task.Delay(action.TimeoutMs)); if (completed ! execTask) { return StepResult.Timeout; } var result await execTask;这里有个隐患命令任务虽然超时了但后台任务本身可能还在运行如果不做取消处理后续可能有残留动作继续执行。我的实践是给ExecuteCommandAsync传入CancellationToken超时后主动取消无法取消的设备操作至少要在日志中标记“超时但任务可能仍在执行”。6.5 内部Trace输出比远程调试更管用上位机开发中挂着Visual Studio调试器看变量状态是常态但DLL一旦发布到客户现场远程调试不方便。我后来在DLL内部加了大量Trace输出用Trace.WriteLine记录关键节点的进入、退出、参数和结果。只要客户安装了DebugView就能实时看到DLL内部全部执行轨迹问题的定位效率提升了非常多。哪怕正式发布我也不会删掉这些Trace只会在日志级别上过滤一下。7. 工程化落地还需要补的几块拼图一个流程编程DLL从“能跑”到“好用”差的往往不是核心代码而是工程化配套设施。7.1 版本管理策略主程序和DLL如果是分开迭代的一定要把接口版本纳入命名管理。我的做法是namespace FlowEngine.V1 { }如果你改了接口签名直接升级为V2命名空间旧版本的DLL仍然保留。这样老程序不会因为DLL更新而崩掉。一次简单的接口变更导致产线停机的事故经历一次就够了。7.2 自动化回归测试流程DLL的逻辑是有状态、有顺序的手工测试很容易遗漏分支。我把模拟设备适配器接到自动化测试框架里每次修改流程引擎都跑一遍完整的回归脚本。测试用例本质上就是各种配方文件和对应的预期结果正常流程走通步骤失败跳转报警超时重试行为变量替换正确性取消流程能及时中止有了这套测试我才敢放心地改DLL核心代码。没有自动化测试的流程引擎本质上就是一颗定时炸弹。7.3 扩展思路从设备控制到更宽泛的流程编排把眼光放远一点这套流程DLL不只是用来控制PLC或者运动控制卡它可以抽象成一切“顺序执行加条件分支”的场景老化测试流程、数据采集流程、批量文件处理流程、甚至运维巡检流程。把设备和业务解耦之后你手里的这个DLL不是某一个上位机的私有库而是一个通用的流程编排引擎。这也是“多功能”三个字最有含金量的地方。我在实际项目中确实不止一次把同一个流程DLL用在不同设备的上位机里只是适配器不同、配方不同核心引擎一行没改。这种复用带来的开发效率是实实在在的也是做架构设计最有成就感的部分。最后再分享一个小技巧当你在流程里遇到某个动作的结果不稳定时先别急着改代码逻辑试着在配方里把它拆成两个步骤中间加一个延时或重试动作。很多时候流程问题用流程的方式解决远比改代码来得干净。这也是我做完这个DLL之后才真正体会到的。
返回列表