C# 上位机开发 - 工厂模式在设备驱动中的应用(一篇搞定 建议收藏)
作者:萨达大 | 标签:工厂模式、设备驱动、上位机、配置化
导读:现场常见「同一套软件,A 线 Modbus RTU,B 线 Modbus TCP,C 线厂家私有协议」。用工厂 + 配置 创建
IDeviceDriver,UI 与业务层只依赖接口,换设备不改代码。
一、问题场景:if-else 地狱
不少项目启动类里写着:
csharp
if (settings.UseTcp)
driver = new ModbusTcpDriver(...);
else if (settings.UseRtu)
driver = new ModbusRtuDriver(...);
else
driver = new CustomProtocolDriver(...);
窗体、服务、测试里复制粘贴三遍,新增一种驱动就要改 N 处。工厂模式 把「创建哪种驱动」收敛到一个工厂类 + 一份配置。
二、统一抽象:IDeviceDriver
与分层架构 01 篇一致,驱动层只关心连接与收发(详见上一篇 HAL)。此处扩展为多种实现:
| 实现类 | 适用场景 | 连接参数 |
|---|---|---|
ModbusRtuDriver |
RS485/RS232 | 端口名、波特率、校验 |
ModbusTcpDriver |
以太网 PLC | IP、端口(通常 502) |
CustomProtocolDriver |
扫码枪、称重仪等 | 串口或 TCP + 自定义结束符 |
csharp
public interface IDeviceDriver : IAsyncDisposable
{
string Name { get; }
bool IsConnected { get; }
Task ConnectAsync(CancellationToken ct = default);
Task DisconnectAsync(CancellationToken ct = default);
Task<byte[]> TransceiveAsync(byte[] request, int timeoutMs, CancellationToken ct = default);
}
三、ModbusRtuDriver 示例
csharp
public sealed class ModbusRtuDriver : IDeviceDriver
{
private readonly SerialPort _port;
public string Name => "ModbusRtu";
public bool IsConnected => _port.IsOpen;
public ModbusRtuDriver(string portName, int baudRate, Parity parity)
{
_port = new SerialPort(portName, baudRate, parity, 8, StopBits.One);
}
public Task ConnectAsync(CancellationToken ct = default)
{
if (!_port.IsOpen) _port.Open();
return Task.CompletedTask;
}
public Task DisconnectAsync(CancellationToken ct = default)
{
if (_port.IsOpen) _port.Close();
return Task.CompletedTask;
}
public async Task<byte[]> TransceiveAsync(byte[] request, int timeoutMs, CancellationToken ct = default)
{
_port.Write(request, 0, request.Length);
var buf = new byte[512];
var read = await Task.Run(() => _port.Read(buf, 0, buf.Length), ct);
return buf.AsSpan(0, read).ToArray();
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
_port.Dispose();
}
}
四、ModbusTcpDriver 示例
csharp
public sealed class ModbusTcpDriver : IDeviceDriver
{
private readonly string _host;
private readonly int _port;
private TcpClient? _client;
public string Name => "ModbusTcp";
public bool IsConnected => _client?.Connected == true;
public ModbusTcpDriver(string host, int port = 502)
{
_host = host;
_port = port;
}
public async Task ConnectAsync(CancellationToken ct = default)
{
_client = new TcpClient();
await _client.ConnectAsync(_host, _port, ct);
}
public Task DisconnectAsync(CancellationToken ct = default)
{
_client?.Close();
return Task.CompletedTask;
}
public async Task<byte[]> TransceiveAsync(byte[] request, int timeoutMs, CancellationToken ct = default)
{
if (_client == null) throw new InvalidOperationException("Not connected");
var stream = _client.GetStream();
await stream.WriteAsync(request, ct);
var buf = new byte[512];
var n = await stream.ReadAsync(buf, ct);
return buf.AsSpan(0, n).ToArray();
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
_client?.Dispose();
}
}
五、CustomProtocolDriver(自定义 ASCII 协议)
例如设备返回 ST,GS,+0012.34kg\r\n:
csharp
public sealed class CustomProtocolDriver : IDeviceDriver
{
private readonly SerialPort _port;
public string Name => "CustomAscii";
public bool IsConnected => _port.IsOpen;
public CustomProtocolDriver(string portName, int baudRate)
{
_port = new SerialPort(portName, baudRate);
_port.NewLine = "\r\n";
}
public Task ConnectAsync(CancellationToken ct = default)
{
if (!_port.IsOpen) _port.Open();
return Task.CompletedTask;
}
public Task DisconnectAsync(CancellationToken ct = default)
{
if (_port.IsOpen) _port.Close();
return Task.CompletedTask;
}
public Task<byte[]> TransceiveAsync(byte[] request, int timeoutMs, CancellationToken ct = default)
{
_port.Write(request, 0, request.Length);
var line = _port.ReadLine();
return Task.FromResult(Encoding.ASCII.GetBytes(line));
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
_port.Dispose();
}
}
协议解析仍在协议层;Driver 只保证「发命令、收一行」。
六、DeviceDriverFactory:配置驱动创建
6.1 配置模型
csharp
public sealed class DriverConfig
{
public string DriverType { get; set; } = "ModbusRtu"; // ModbusRtu | ModbusTcp | CustomAscii
public string? PortName { get; set; }
public int BaudRate { get; set; } = 9600;
public string Parity { get; set; } = "Even";
public string? Host { get; set; }
public int TcpPort { get; set; } = 502;
}
6.2 JSON 配置示例(devices.json)
json
{
"LineA": {
"DriverType": "ModbusRtu",
"PortName": "COM3",
"BaudRate": 9600,
"Parity": "Even"
},
"LineB": {
"DriverType": "ModbusTcp",
"Host": "192.168.1.10",
"TcpPort": 502
},
"Scale1": {
"DriverType": "CustomAscii",
"PortName": "COM5",
"BaudRate": 2400
}
}
6.3 工厂实现
csharp
public static class DeviceDriverFactory
{
public static IDeviceDriver Create(DriverConfig cfg)
{
switch (cfg.DriverType)
{
case "ModbusRtu":
var parity = Enum.Parse<Parity>(cfg.Parity);
return new ModbusRtuDriver(cfg.PortName!, cfg.BaudRate, parity);
case "ModbusTcp":
return new ModbusTcpDriver(cfg.Host!, cfg.TcpPort);
case "CustomAscii":
return new CustomProtocolDriver(cfg.PortName!, cfg.BaudRate);
default:
throw new NotSupportedException($"Unknown driver: {cfg.DriverType}");
}
}
public static Dictionary<string, IDeviceDriver> CreateAll(IConfiguration configuration)
{
var section = configuration.GetSection("Devices");
var dict = new Dictionary<string, IDeviceDriver>();
foreach (var child in section.GetChildren())
{
var cfg = child.Get<DriverConfig>() ?? new DriverConfig();
dict[child.Key] = Create(cfg);
}
return dict;
}
}
启动时:
csharp
var drivers = DeviceDriverFactory.CreateAll(builder.Configuration);
var lineAProtocol = new ModbusProtocolClient(drivers["LineA"]);
var service = new DeviceService(lineAProtocol);
换 B 线设备 :只改 devices.json 里 LineB 的 DriverType 与参数,零 UI 改动。
七、与分层架构的配合
#mermaid-svg-wY98tuhVCOL1Ef7M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wY98tuhVCOL1Ef7M .error-icon{fill:#552222;}#mermaid-svg-wY98tuhVCOL1Ef7M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wY98tuhVCOL1Ef7M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wY98tuhVCOL1Ef7M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wY98tuhVCOL1Ef7M .marker.cross{stroke:#333333;}#mermaid-svg-wY98tuhVCOL1Ef7M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wY98tuhVCOL1Ef7M p{margin:0;}#mermaid-svg-wY98tuhVCOL1Ef7M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster-label text{fill:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster-label span{color:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster-label span p{background-color:transparent;}#mermaid-svg-wY98tuhVCOL1Ef7M .label text,#mermaid-svg-wY98tuhVCOL1Ef7M span{fill:#333;color:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M .node rect,#mermaid-svg-wY98tuhVCOL1Ef7M .node circle,#mermaid-svg-wY98tuhVCOL1Ef7M .node ellipse,#mermaid-svg-wY98tuhVCOL1Ef7M .node polygon,#mermaid-svg-wY98tuhVCOL1Ef7M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wY98tuhVCOL1Ef7M .rough-node .label text,#mermaid-svg-wY98tuhVCOL1Ef7M .node .label text,#mermaid-svg-wY98tuhVCOL1Ef7M .image-shape .label,#mermaid-svg-wY98tuhVCOL1Ef7M .icon-shape .label{text-anchor:middle;}#mermaid-svg-wY98tuhVCOL1Ef7M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wY98tuhVCOL1Ef7M .rough-node .label,#mermaid-svg-wY98tuhVCOL1Ef7M .node .label,#mermaid-svg-wY98tuhVCOL1Ef7M .image-shape .label,#mermaid-svg-wY98tuhVCOL1Ef7M .icon-shape .label{text-align:center;}#mermaid-svg-wY98tuhVCOL1Ef7M .node.clickable{cursor:pointer;}#mermaid-svg-wY98tuhVCOL1Ef7M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wY98tuhVCOL1Ef7M .arrowheadPath{fill:#333333;}#mermaid-svg-wY98tuhVCOL1Ef7M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wY98tuhVCOL1Ef7M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wY98tuhVCOL1Ef7M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wY98tuhVCOL1Ef7M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wY98tuhVCOL1Ef7M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wY98tuhVCOL1Ef7M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster text{fill:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M .cluster span{color:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wY98tuhVCOL1Ef7M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wY98tuhVCOL1Ef7M rect.text{fill:none;stroke-width:0;}#mermaid-svg-wY98tuhVCOL1Ef7M .icon-shape,#mermaid-svg-wY98tuhVCOL1Ef7M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wY98tuhVCOL1Ef7M .icon-shape p,#mermaid-svg-wY98tuhVCOL1Ef7M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wY98tuhVCOL1Ef7M .icon-shape .label rect,#mermaid-svg-wY98tuhVCOL1Ef7M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wY98tuhVCOL1Ef7M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wY98tuhVCOL1Ef7M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wY98tuhVCOL1Ef7M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} devices.json
DeviceDriverFactory
IDeviceDriver
ModbusProtocolClient
DeviceService
MainForm
工厂只出现在应用启动 / DI 注册 阶段,业务代码不出现 new ModbusRtuDriver。
八、依赖注入(可选进阶)
ASP.NET Core 风格注册:
csharp
services.AddSingleton<IDeviceDriver>(sp =>
{
var cfg = sp.GetRequiredService<IConfiguration>().GetSection("Devices:LineA").Get<DriverConfig>()!;
return DeviceDriverFactory.Create(cfg);
});
services.AddSingleton<IProtocolClient>(sp => new ModbusProtocolClient(sp.GetRequiredService<IDeviceDriver>()));
services.AddSingleton<DeviceService>();
WinForms 可在 Program.cs 里手动组装,原理相同。
九、切换设备实战 checklist
| 步骤 | 操作 |
|---|---|
| 1 | 确认新设备物理连接(COM/IP) |
| 2 | 修改 JSON 中 DriverType 与参数 |
| 3 | 若协议不同,换 IProtocolClient 实现(仍通过工厂或第二个工厂) |
| 4 | 业务寄存器地址在配置表维护,不改 Form |
| 5 | 用 Mock Driver 跑一遍回归测试 |
十、常见问题 FAQ
Q1:工厂和抽象工厂有什么区别?
本文是简单工厂(按字符串创建),上位机足够用。若驱动种类爆炸且族系复杂,可升级为抽象工厂 + 产品族,但维护成本更高。
Q2:多个设备共用一个串口(网关)怎么办?
一个 ModbusRtuDriver 实例,多个 ModbusProtocolClient 共用同一 Driver 时需加互斥锁,或网关 SDK 在 HAL 内排队。
Q3:配置放 JSON 还是数据库?
单机工控机 JSON + 热更新已够用;多产线 MES 下发可用数据库,工厂读表生成 DriverConfig。
Q4:DriverType 用字符串还是枚举?
配置文件用字符串可读性好;工厂内 switch 或字典注册 Func<DriverConfig, IDeviceDriver> 避免巨型 switch。
Q5:如何单元测试工厂?
注入临时 JSON,Create 后断言 Name、IsConnected 为 false;用 Mock 串口需接口隔离或 ISerialPortWrapper。
Q6:和 01 篇 HalSerialPort 重复吗?
HalSerialPort 是教学用 HAL;ModbusRtuDriver 是现场命名。可合并为一个类,只要实现 IDeviceDriver 即可。
十一、小结
- 所有硬件差异收敛到
IDeviceDriver实现类。 DeviceDriverFactory+ JSON 实现换线不换代码。- UI 只依赖
DeviceService,不依赖具体 Driver。
下一篇介绍轮询数据如何推送到界面------观察者模式与绑定。
上一篇 :01 上位机分层架构设计
下一篇 :03 观察者模式与数据绑定