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