虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预

做制造业软件有个绕不开的难题:软件要对接的产线,经常还不存在,或者不可能为了联调把它停下来。我们需要一个东西,能在没有真实设备的情况下,扮演一条行为可信的产线------它会按节拍加工,也会停机、报警、缺料、堵料,并且对外暴露一组能用真实协议读写的点位,让 MES、上位机把它当真设备来对接。

这就是这套虚拟工厂仿真引擎要解决的问题。技术栈是 .NET 8。这篇文章讲它的架构:整体怎么分层、四个核心模型怎么组合、运行时主循环和产线总线怎么设计,以及每个关键决策背后的取舍和它换来的价值。

一、先定架构目标

动手之前,我把这套引擎要满足的约束列清楚了,后面所有设计都是冲着它们去的:

  • 可对接:对外只暴露点位和 HTTP/SSE 接口,行为要像真设备,MES 不需要知道背后是仿真。
  • 可复现异常 :它的价值恰恰在于能让"设备停机""下游堵料""物料短缺"这些异常工况可控地发生,而不是只跑顺利路径。
  • 可观测、可干预:运行态要能被外部看见,卡住的地方要能由人介入恢复------因为它是给别人做联调的底座,黑盒没有意义。
  • 节拍可控:秒级 tick 驱动,性能不是首要矛盾,确定性和透明才是。

记住"可复现异常"和"可观测、可干预"这两条,它们解释了后面很多看起来"不够高级"的选择。

二、分层:把"工厂是什么"和"工厂怎么跑"彻底分开

整个解决方案拆成七个工程,依赖严格单向:

graph TD Host[&#34;Host.Api · 唯一宿主<br/>API / SSE / WebSocket / 静态前端&#34;] App[&#34;Application · 接口契约 + DTO&#34;] Domain[&#34;Domain · 纯领域模型&#34;] Sim[&#34;Simulation · 运行时引擎&#34;] Script[&#34;Scripting · Roslyn 脚本&#34;] Cms[&#34;Integration.Cms · 点位系统客户端&#34;] Infra[&#34;Infrastructure · 仓储 / 文件队列 / SQLite&#34;] Host --> App & Sim & Infra & Script & Cms Sim --> App & Domain Script --> App Infra --> App & Domain Cms --> App App --> Domain

这套分层有三个刻意为之的边界,每一个都对应一个具体好处:

Domain 不依赖任何人。 EquipmentTemplateWorkstationDefinitionWorkpieceStateProductLineDefinition 全是纯 record/POCO,不碰 IO、HTTP、数据库。它们只回答"工厂是什么"。好处是领域模型可以独立演进、独立做单元测试,改一个运行时实现不会动到模型定义。

Application 只放接口和 DTO。 比如 IWorkstationRuntimeILineBusGatewayIProcessCapability。Simulation 依赖这些抽象,Infrastructure 提供实现,两边在 Host 里靠 DI 拼起来。好处是仿真层根本不关心"队列到底是文件还是 Redis、点位到底是真 PLC 还是 Mock"------实现可以整个换掉,仿真逻辑一行不用动。

Simulation 是唯一"会动"的层。 主循环、状态机、调度都在这里,但它只认接口。

前后端则是代码分离、单宿主承载:前端产物发布到宿主里,所有数据走 /api、SSE、WebSocket,前端不允许直接碰场景目录、队列、数据库或运行时对象。这条约束的价值是:运行态对外只有一个出口,任何接入方(包括前端)都走同一套契约,没有抄近路的暗门,系统边界因此是清晰可控的。

三、四个核心模型,以及它们怎么组合

领域模型就四个词:设备、工艺、工位、工序。它们的组合关系比定义本身更重要:

graph LR subgraph 工序[&#34;工序 · 调度单元&#34;] P[&#34;按路由表 from→to 串联<br/>底下挂多个工位并行&#34;] end W[&#34;工位 = 设备 + 工艺<br/>管加工槽位 + 缓存区&#34;] Eq[&#34;设备模型<br/>状态/报警/心跳/易损件&#34;] Pm[&#34;工艺模型<br/>动作序列 + 脚本&#34;] P --> W W -->|绑定| Eq W -->|绑定| Pm
  • 设备模型 描述一台机器的健康度:什么变量等于什么值算 Run、心跳丢了算掉线、哪个报警强制故障、易损件还剩多少寿命。它不是死值,而是一组求值规则------运行时把点位实时值套进去,算出一个快照,其中最关键的字段是"这台设备此刻能不能让工位干活"。
  • 工艺模型描述一道工序要做哪些动作、什么顺序。
  • 工位把一台设备和一个工艺绑在一起,自管若干加工槽位(并发能力)和一个缓存区(槽位满了排队)。
  • 工序是调度单元,底下可挂多个工位并行,工序间用路由表连成产线拓扑(支持串行、并行分支、多工序汇聚)。

这套拆分的价值在于关注点分离:设备的健康度逻辑、工艺的动作逻辑、工位的产能与节拍、工序的拓扑与流转,四件事各自独立配置、独立变化。换一台设备不影响工艺,改一道工艺不影响产线拓扑。

一个产品在系统里是一个 WorkpieceState:一份不可变身份(SN、产品码、批次)加一组按工序隔离 的参数(P01:torqueP02: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 里享有最高优先级,慢的加工不会反过来卡住整条链路的流转。这是一个用执行顺序换吞吐稳定性的设计。

sequenceDiagram participant Orch as 主循环 participant Bus as 文件队列总线 participant WS as 工位 participant Eq as 设备求值器 loop 每个 Tick Orch->>Bus: 1 上游产出→下游投入 Orch->>Bus: 2 工位按工序取件 Bus->>WS: TryAccept Orch->>Eq: 取设备快照 Orch->>WS: 3 推进 Tick WS-->>Orch: 完工出件 Orch->>Bus: 4 收件写产出 end

配置热重载也是一个有意识的取舍:我没用 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 根本不是瓶颈。这是一次明确的取舍:在性能不是瓶颈的前提下,用透明性换高级感 ------我要一个用 lscurl 就能理解和操作的系统。

总线的精华是背压。下游能不能接料,算一笔容量账:

复制代码
工序总容量 = Σ工位加工槽位 + Σ工位缓存容量
当前负载   = Σ占用槽位 + Σ缓存件 + 投入队列待取件

下游满载时,上游产出消息进 blocked 目录,同时上游整道工序的工位一起转 ManualPaused,背压由此一级级往上游传导。

flowchart TD A[工位完工] --> B[写入本工序产出] B --> C{&#34;下游有容量?<br/>总容量 vs 当前负载&#34;} C -- 有 --> D[调拨到下游投入] --> E[下游工位取走] C -- 没有 --> F[消息进 blocked] F --> G[&#34;上游整道工序工位<br/>转 ManualPaused&#34;] G --> H[人工确认下游已消化] H --> I[&#34;调 API:blocked 件回队列 + 工位恢复&#34;] I --> C

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,让它自己配产线、给工位生成工艺脚本,以及这背后的编排与安全架构。

相关推荐
字节跳动数据库1 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横1 小时前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
ZzT2 小时前
让 AI 少写一半代码:拆解爆火的 ponytail
ai编程·claude
用户6757049885022 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan2 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885022 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
Bigger2 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
tntxia2 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530142 小时前
Java 实现 Word 文档加密与权限解除
java·后端