发布者:凯思软件发布日期:2026-07-15浏览量:

做制造业软件有个绕不开的难题:软件要对接的产线,经常还不存在,或者不可能为了联调把它停下来。我们需要一个东西,能在没有真实设备的情况下,扮演一条行为可信的产线——它会按节拍加工,也会停机、报警、缺料、堵料,并且对外暴露一组能用真实协议读写的点位,让MES、上位机把它当真设备来对接。
这就是这套虚拟工厂仿真引擎要解决的问题。技术栈是.NET8。这篇文章讲它的架构:整体怎么分层、四个核心模型怎么组合、运行时主循环和产线总线怎么设计,以及每个关键决策背后的取舍和它换来的价值。
动手之前,我把这套引擎要满足的约束列清楚了,后面所有设计都是冲着它们去的:
l 可对接:对外只暴露点位和HTTP/SSE接口,行为要像真设备,MES不需要知道背后是仿真。
l 可复现异常:它的价值恰恰在于能让"设备停机""下游堵料""物料短缺"这些异常工况可控地发生,而不是只跑顺利路径。
l 可观测、可干预:运行态要能被外部看见,卡住的地方要能由人介入恢复——因为它是给别人做联调的底座,黑盒没有意义。
l 节拍可控:秒级tick驱动,性能不是首要矛盾,确定性和透明才是。
记住"可复现异常"和"可观测、可干预"这两条,它们解释了后面很多看起来"不够高级"的选择。
整个解决方案拆成七个工程,依赖严格单向:
这套分层有三个刻意为之的边界,每一个都对应一个具体好处:
Domain不依赖任何人。EquipmentTemplate、WorkstationDefinition、WorkpieceState、ProductLineDefinition全是纯record/POCO,不碰IO、HTTP、数据库。它们只回答"工厂是什么"。好处是领域模型可以独立演进、独立做单元测试,改一个运行时实现不会动到模型定义。
Application只放接口和DTO。比如IWorkstationRuntime、ILineBusGateway、IProcessCapability。Simulation依赖这些抽象,Infrastructure提供实现,两边在Host里靠DI拼起来。好处是仿真层根本不关心"队列到底是文件还是Redis、点位到底是真PLC还是Mock"——实现可以整个换掉,仿真逻辑一行不用动。
Simulation是唯一"会动"的层。主循环、状态机、调度都在这里,但它只认接口。
前后端则是代码分离、单宿主承载:前端产物发布到宿主里,所有数据走/api、SSE、WebSocket,前端不允许直接碰场景目录、队列、数据库或运行时对象。这条约束的价值是:运行态对外只有一个出口,任何接入方(包括前端)都走同一套契约,没有抄近路的暗门,系统边界因此是清晰可控的。
领域模型就四个词:设备、工艺、工位、工序。它们的组合关系比定义本身更重要:
l 设备模型描述一台机器的健康度:什么变量等于什么值算Run、心跳丢了算掉线、哪个报警强制故障、易损件还剩多少寿命。它不是死值,而是一组求值规则——运行时把点位实时值套进去,算出一个快照,其中最关键的字段是"这台设备此刻能不能让工位干活"。
l 工艺模型描述一道工序要做哪些动作、什么顺序。
l 工位把一台设备和一个工艺绑在一起,自管若干加工槽位(并发能力)和一个缓存区(槽位满了排队)。
l 工序是调度单元,底下可挂多个工位并行,工序间用路由表连成产线拓扑(支持串行、并行分支、多工序汇聚)。
这套拆分的价值在于关注点分离:设备的健康度逻辑、工艺的动作逻辑、工位的产能与节拍、工序的拓扑与流转,四件事各自独立配置、独立变化。换一台设备不影响工艺,改一道工艺不影响产线拓扑。
一个产品在系统里是一个WorkpieceState:一份不可变身份(SN、产品码、批次)加一组按工序隔离的参数(P01:torque、P02:temperature),保证同一工件在不同工序产出的数据互不覆盖。
整个仿真由一个BackgroundService驱动,默认每5秒一个tick。一个tick内的动作顺序是经过设计的,不是随意排列:
顺序定成"调拨→取件→推进→收集",是因为第三步推进最慢(要查设备快照、可能跑脚本)。把调度和取件排在推进之前,保证的是"产能往前流动"这件事在每个tick里享有最高优先级,慢的加工不会反过来卡住整条链路的流转。这是一个用执行顺序换吞吐稳定性的设计。
配置热重载也是一个有意识的取舍:我没用FileSystemWatcher,而是每轮tick对场景配置算SHA256指纹,变了才重载。原因是文件监听在容器、网络盘上行为不可靠;用一次哈希换确定性,对秒级tick来说成本可以忽略。
工位是引擎里状态最复杂的对象,它的控制逻辑集中体现在"要不要接下一个件"上。我把它设计成一串分层闸门——越靠前的是硬性物理约束,越靠后的是软性资源约束:
l 物理约束短路在前。设备停机时做什么都没用,先挡掉,避免无谓计算。
l 有副作用的操作压到最后。物料预占成功就意味着投料文件已被改写或删除(批次料减一、唯一料删文件)。放最后,是为了避免"预占成功但后面闸门又退件"导致的白扣料。这是把"有副作用的步骤放在所有否决条件之后"的通用原则。
l 拒收都带原因。每道闸门返回的都是带理由的RetryLater,这直接服务于"可观测"目标——任何一次没接料,外部都知道是为什么。
并发上,工位的三个入口(接件TryAccept、推进TickAsync、出件TryDequeueDeparted)共用一把SemaphoreSlim。因为它们都会动槽位、缓存、状态,而主循环又是连着调它们的。用单锁把三个入口串行化,换来的是"绝不会读到改了一半的状态"这个强保证,代价是工位内部无并行——对秒级tick完全够用。
工位一共九个状态,其中ManualPaused只能人工恢复,代码里不给它任何自动复活路径。这不是限制,是下一节背压设计的一部分。
工位之间不直连,所有产品流转都过一条"产线总线"。总线的实现,我选了文件系统队列。每个工序两个队列目录:一个投入、一个产出。流转是"产出→调拨→取走"三步,且总线不指定具体工位,工序底下哪个工位有空哪个抢。
为什么放着内存队列和MQ不用,选了看起来更"土"的文件队列?因为它精准命中了前面定的三个架构目标:
l 可观测:产品堵在哪、堵了多少,ls一下队列目录就知道,不必连进程看内存。
l 可恢复:队列是文件,进程崩了重启目录还在;工件加工到一半进程挂了也不丢——每推进一个动作阶段就落一个checkpoint,启动时捞回来断点续跑。
l 可干预:这是关键,直接支撑背压恢复。
代价是IO慢,但秒级tick下IO根本不是瓶颈。这是一次明确的取舍:在性能不是瓶颈的前提下,用透明性换高级感——我要一个用ls和curl就能理解和操作的系统。
总线的精华是背压。下游满载时,上游产出消息进blocked目录,同时上游整道工序的工位一起转ManualPaused,背压由此一级级往上游传导。
工位上动作真正"干活"的部分,是一个IProcessCapability,有两条来源:内置能力(进站、出站、模拟拧紧等)和挂载脚本。脚本用Roslyn跑C#——选C#而非Lua/JS,因为宿主就是C#,脚本与引擎同语言、类型互通、编译结果可缓存。
无论哪条来源,执行结果都归一到同一个出口:CapabilityOutcome(成功/不合格/重试/阻塞/故障/旁通)。调度器只认这个枚举来决定工件命运。这个统一出口是关键的解耦点:写工艺脚本的人只要写Outcome="NG",引擎就接管后面的判废和路由,业务表达和运行时机制因此完全分离,工艺工程师不需要懂状态机。新增一种能力,也只是多注册一个实现,不动调度器。
点位最终落在外部点位系统(CMS)上,这层被抽象成接口,并提供Mock/真实两套实现可切换。Mock模式下有个后台服务自动扮演PLC,把进站握手补全。好处很实在:不接任何真设备,整条线也能自跑,本地开发和CI都不依赖外部环境——这正是"可对接"目标的另一面,接入是可选的而非必需的。
实时推送也做了优化:不是每个tick都把整快照推给前端,而是对前端可见的所有字段算指纹,没变不推,只在真变化或心跳时发一帧,每个订阅者一个容量8、满了丢最旧的有界通道。好处是慢消费者撑不爆内存,而可视化场景丢几帧中间态无所谓——前端要的本就是最新态。
把这套引擎从头看一遍,真正塑造它的不是用了什么框架,而是几条贯穿始终的取向:
l 确定性优先:指纹比对代替文件监听,单锁串行化代替无锁并发,同语言脚本代替嵌入式解释器。
l 可观测优先:队列是目录、堵料是文件、拒收带原因、状态显式——系统此刻在干什么,从外部一眼可知。
l 可干预是功能:背压人工恢复不是缺陷,而是这套联调底座有意提供的控制点。
l 解耦换可演进:分层、模型拆分、能力统一出口,每一处都是为了让某一部分能独立变化。
这些选择单看都"不够炫",但合起来,是一条我从外部就能完全掌控、随时知道它在做什么的产线。对一个用来给别人当联调底座的仿真器,这种可控和透明,就是它最大的技术价值。
