1. 工控上位机开发的特殊性与挑战
工控上位机开发与普通桌面应用开发存在本质区别。在工业现场,程序需要面对的是7×24小时不间断运行、恶劣的电磁环境、老旧的工控硬件设备,以及各种非标准的通信协议。一个在开发环境中运行良好的程序,到了现场可能因为内存泄漏、线程死锁、通信超时等问题导致整个产线停机。
我在智能仓储项目中曾遇到一个典型案例:一个看似简单的数据采集程序,在实验室测试时一切正常,但部署到现场后每隔3天就会崩溃一次。经过排查发现是因为未正确处理串口通信的超时情况,导致线程阻塞积累最终耗尽系统资源。这种问题在普通办公环境中可能永远不会暴露,但在工业现场就是致命缺陷。
关键经验:工控程序必须按照"永远不信任外部设备"的原则来设计。任何外部通信、数据输入、硬件交互都要预设可能发生的异常情况。
2. 跨线程直接操作UI控件(工控Top1高频崩溃)
2.1 问题现象与危害
在WPF或WinForm中直接在工作线程中更新UI控件,轻则导致界面卡顿、数据不同步,重则引发程序崩溃。这是工控上位机中最常见的问题之一,因为数据采集、设备通信等耗时操作通常需要在后台线程执行,而采集到的数据又需要实时显示在界面上。
csharp复制// 错误示例:在工作线程中直接更新UI
private void CommunicationThread()
{
while(true)
{
var data = ReadDeviceData(); // 从设备读取数据
lblValue.Text = data.ToString(); // 直接更新UI控件 - 会导致崩溃!
}
}
2.2 根本原因分析
Windows UI框架基于单线程公寓模型(STA),所有UI操作必须在创建控件的线程(通常是主线程)上执行。跨线程直接操作UI会破坏这个模型,导致不可预知的行为。
2.3 正确解决方案
WPF和WinForm都提供了安全的跨线程UI更新机制:
WPF解决方案:
csharp复制private void CommunicationThread()
{
while(true)
{
var data = ReadDeviceData();
Application.Current.Dispatcher.Invoke(() =>
{
lblValue.Content = data.ToString();
});
}
}
WinForm解决方案:
csharp复制private void CommunicationThread()
{
while(true)
{
var data = ReadDeviceData();
this.Invoke((MethodInvoker)delegate
{
lblValue.Text = data.ToString();
});
}
}
2.4 性能优化技巧
高频更新UI时,直接使用Invoke会导致性能问题。可以采用以下优化策略:
- 批量更新:累积一定数据量后再更新UI,而不是每个数据点都更新
- BeginInvoke替代Invoke:BeginInvoke是异步的,不会阻塞工作线程
- 数据绑定+INotifyPropertyChanged:WPF中推荐使用数据绑定机制
3. 异步任务无取消机制,资源泄漏且无法终止程序
3.1 问题场景
工控程序经常需要执行长时间运行的任务,如持续数据采集、设备监控等。如果这些任务没有正确的取消机制,会导致两个严重问题:
- 程序关闭时任务无法正常终止,需要强制结束进程
- 任务中分配的资源无法及时释放,造成内存泄漏
3.2 错误示例分析
csharp复制private void StartMonitoring()
{
Task.Run(() =>
{
while(true) // 无限循环,没有退出机制
{
// 监控逻辑...
}
});
}
3.3 使用CancellationToken实现优雅终止
.NET提供了CancellationToken机制来实现任务的优雅取消:
csharp复制private CancellationTokenSource _cts;
private void StartMonitoring()
{
_cts = new CancellationTokenSource();
Task.Run(() =>
{
while(!_cts.Token.IsCancellationRequested)
{
// 监控逻辑...
// 适当的位置检查取消请求
_cts.Token.ThrowIfCancellationRequested();
}
}, _cts.Token);
}
private void StopMonitoring()
{
_cts?.Cancel(); // 请求取消任务
_cts?.Dispose();
}
3.4 资源清理的最佳实践
即使任务被取消,也要确保资源被正确释放:
csharp复制private async Task MonitorAsync(CancellationToken ct)
{
try
{
using(var resource = new SomeResource())
{
while(!ct.IsCancellationRequested)
{
// 使用资源...
await Task.Delay(1000, ct);
}
} // 离开using块时资源会自动释放
}
catch(OperationCanceledException)
{
// 任务被取消,正常退出
}
}
4. 工业通信缺失超时/重连逻辑,线程阻塞整机卡死
4.1 工业通信的特殊性
工业现场的设备通信(Modbus、OPC UA、串口等)面临诸多挑战:
- 设备可能突然断电
- 通信线缆可能被干扰或断开
- 设备响应可能非常缓慢
4.2 常见错误模式
csharp复制// 错误示例:没有超时机制的串口读取
public string ReadSerialPort()
{
serialPort.Open();
string data = serialPort.ReadLine(); // 可能永远阻塞在这里
serialPort.Close();
return data;
}
4.3 健壮的通信框架设计
一个健壮的工业通信模块应包含以下机制:
- 超时控制:为每个操作设置合理超时
- 自动重连:连接断开后自动尝试恢复
- 错误隔离:单个设备通信失败不应影响其他设备
- 状态监控:实时记录通信状态和质量
csharp复制public async Task<string> ReadSerialPortWithTimeoutAsync(int timeoutMs)
{
try
{
serialPort.Open();
var readTask = Task.Run(() => serialPort.ReadLine());
var timeoutTask = Task.Delay(timeoutMs);
var completedTask = await Task.WhenAny(readTask, timeoutTask);
if(completedTask == timeoutTask)
{
throw new TimeoutException("串口读取超时");
}
return await readTask;
}
finally
{
serialPort.Close();
}
}
4.4 重连策略实现
csharp复制public async Task<DeviceData> ReadDeviceWithRetryAsync(int maxRetries)
{
int retryCount = 0;
while(true)
{
try
{
return await ReadDeviceAsync();
}
catch(Exception ex) when (retryCount < maxRetries)
{
retryCount++;
await Task.Delay(1000 * retryCount); // 指数退避
Reconnect(); // 尝试重新连接
}
}
}
5. 异常处理不规范:吞异常、捕获范围错误
5.1 工控系统中的异常处理原则
- 不吞没异常:catch块至少应该记录异常
- 特定异常类型:避免捕获过于通用的Exception
- 资源清理:使用using或try-finally确保资源释放
- 上下文信息:记录异常发生时的系统状态
5.2 反模式示例
csharp复制try
{
// 一些操作...
}
catch(Exception)
{
// 完全吞没异常 - 绝对禁止!
}
5.3 正确的异常处理框架
csharp复制public void ProcessDeviceData()
{
try
{
using(var device = ConnectToDevice())
{
var data = device.ReadData();
ProcessData(data);
}
}
catch(DeviceNotRespondingException ex)
{
Logger.Error($"设备无响应: {ex.Message}");
ReconnectDevice();
}
catch(DataFormatException ex)
{
Logger.Error($"数据格式错误: {ex.Message}");
SendAlert("数据格式异常");
}
catch(Exception ex)
{
Logger.Error($"未处理的异常: {ex}");
throw; // 重新抛出,由上层处理
}
}
5.4 日志记录的最佳实践
工控系统应记录详细的运行日志:
- 使用结构化日志框架(如Serilog、NLog)
- 包含上下文信息(时间戳、线程ID、设备状态等)
- 合理设置日志级别(Debug、Info、Warning、Error)
- 实现日志轮转,避免日志文件过大
csharp复制// Serilog配置示例
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File("logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7)
.CreateLogger();
// 记录结构化日志
Log.Information("从设备{DeviceId}读取数据: {Data}", deviceId, data);
6. 非托管资源未释放,导致内存/句柄泄漏
6.1 非托管资源的类型
工控程序中常见的非托管资源:
- 文件句柄
- 串口/并口资源
- 网络连接
- 设备驱动程序
- GDI对象(在WinForm中)
6.2 资源泄漏的后果
- 长时间运行后程序崩溃
- 系统资源耗尽影响其他程序
- 需要定期重启程序才能维持运行
6.3 正确管理非托管资源
实现IDisposable模式
csharp复制public class DeviceController : IDisposable
{
private IntPtr _deviceHandle;
private bool _disposed = false;
public DeviceController()
{
_deviceHandle = OpenDevice();
}
protected virtual void Dispose(bool disposing)
{
if(!_disposed)
{
if(disposing)
{
// 释放托管资源
}
// 释放非托管资源
if(_deviceHandle != IntPtr.Zero)
{
CloseDevice(_deviceHandle);
_deviceHandle = IntPtr.Zero;
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~DeviceController()
{
Dispose(false);
}
}
使用模式
csharp复制// 推荐用法 - using语句
using(var device = new DeviceController())
{
device.PerformOperation();
} // 自动调用Dispose()
// 当无法使用using时
DeviceController device = null;
try
{
device = new DeviceController();
device.PerformOperation();
}
finally
{
device?.Dispose();
}
6.4 检测资源泄漏的工具
- Process Explorer:查看进程的句柄计数
- PerfMon:监控内存使用情况
- Visual Studio诊断工具:分析内存使用情况
- DotMemory:专业的内存分析工具
7. 数据类型转换未校验,数值解析触发程序崩溃
7.1 工业数据的特点
工业设备返回的数据往往:
- 格式不规范(前导/后导空格、非常规分隔符等)
- 可能包含非预期字符(如"N/A"、"ERROR"等)
- 超出预期的数值范围
7.2 常见错误
csharp复制// 错误示例:假设数据总是有效的数字
int value = int.Parse(deviceResponse);
7.3 健壮的数据解析方法
使用TryParse模式
csharp复制if(int.TryParse(deviceResponse, out int value))
{
// 成功解析
}
else
{
// 处理无效数据
Log.Warning("无效的数值格式: {Response}", deviceResponse);
}
自定义解析器
csharp复制public static bool TryParseIndustrialValue(string input, out double value)
{
value = 0;
if(string.IsNullOrWhiteSpace(input))
return false;
// 处理特殊标记
if(input.Equals("N/A", StringComparison.OrdinalIgnoreCase))
return false;
// 移除可能的单位符号
input = Regex.Replace(input, @"[^\d.-]", "");
return double.TryParse(input, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
}
7.4 数据验证框架
对于复杂的数据结构,可以实现验证框架:
csharp复制public class DataValidator
{
public ValidationResult ValidateTemperature(string input)
{
if(!TryParseIndustrialValue(input, out double temp))
return ValidationResult.Invalid("无效的温度格式");
if(temp < -20 || temp > 100)
return ValidationResult.Invalid("温度超出合理范围");
return ValidationResult.Valid(temp);
}
}
public class ValidationResult
{
public bool IsValid { get; }
public string ErrorMessage { get; }
public object Value { get; }
private ValidationResult(bool isValid, string error, object value)
{
IsValid = isValid;
ErrorMessage = error;
Value = value;
}
public static ValidationResult Valid(object value) => new ValidationResult(true, null, value);
public static ValidationResult Invalid(string error) => new ValidationResult(false, error, null);
}
8. 高频UI刷新无优化,界面闪烁/主线程阻塞
8.1 工控UI的特殊要求
工控上位机通常需要:
- 实时显示大量数据(如曲线图、仪表盘等)
- 快速响应操作指令
- 保持界面流畅不卡顿
8.2 常见性能问题
- 过度绘制:频繁更新整个控件而非局部
- 主线程阻塞:在UI线程执行耗时操作
- 内存泄漏:未正确卸载事件处理程序
8.3 WPF性能优化技巧
虚拟化长列表
xml复制<ListView VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Recycling">
<!-- 列表项模板 -->
</ListView>
使用CompositionTarget.Rendering进行高效动画
csharp复制private void StartAnimation()
{
CompositionTarget.Rendering += UpdateAnimation;
}
private void UpdateAnimation(object sender, EventArgs e)
{
// 更新动画状态
// 注意:这里仍然在UI线程执行,不能做耗时操作
}
private void StopAnimation()
{
CompositionTarget.Rendering -= UpdateAnimation;
}
数据绑定优化
csharp复制// 使用ObservableCollection时,批量更新使用AddRange
public static class ObservableCollectionExtensions
{
public static void AddRange<T>(this ObservableCollection<T> collection, IEnumerable<T> items)
{
foreach(var item in items)
{
collection.Add(item);
}
// 或者使用反射调用私有方法
// collection.GetType().GetMethod("CheckReentrancy")?.Invoke(collection, null);
// var itemsField = collection.GetType().GetField("items", BindingFlags.Instance | BindingFlags.NonPublic);
// var itemsList = (List<T>)itemsField.GetValue(collection);
// itemsList.AddRange(items);
// collection.GetType().GetMethod("OnCollectionChanged")?.Invoke(collection, new object[] { NotifyCollectionChangedAction.Reset });
}
}
8.4 WinForm双缓冲与绘图优化
csharp复制// 启用双缓冲
public class DoubleBufferedPanel : Panel
{
public DoubleBufferedPanel()
{
this.DoubleBuffered = true;
this.SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
this.SetStyle(ControlStyles.AllPaintingInWmPaint, true);
this.SetStyle(ControlStyles.UserPaint, true);
}
}
// 高效绘图
protected override void OnPaint(PaintEventArgs e)
{
// 只绘制可见区域
var clipRect = e.ClipRectangle;
// 使用位图缓存复杂图形
if(_backBuffer == null || _backBuffer.Size != this.Size)
{
_backBuffer?.Dispose();
_backBuffer = new Bitmap(this.Width, this.Height);
}
using(var g = Graphics.FromImage(_backBuffer))
{
// 在后台缓冲区绘制
RenderToGraphics(g);
}
// 一次性绘制到屏幕
e.Graphics.DrawImageUnscaled(_backBuffer, Point.Empty);
}
9. 配置硬编码,现场适配性差
9.1 工控软件的配置需求
工控软件通常需要适应:
- 不同的设备型号和参数
- 变化的通信协议和地址
- 现场特定的业务流程
- 多语言支持
9.2 配置管理的反模式
csharp复制// 硬编码配置 - 难以适应不同现场
public class DeviceManager
{
private const string COM_PORT = "COM3";
private const int BAUD_RATE = 9600;
private const int TIMEOUT = 1000;
// ...
}
9.3 灵活的配置架构
分层配置设计
- 默认配置:内置在程序中的默认值
- 应用配置:应用程序级别的设置(如config文件)
- 站点配置:特定现场的设置(优先级最高)
csharp复制public class AppConfig
{
private readonly IConfiguration _config;
public AppConfig()
{
// 加载默认配置
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.default.json", optional: true)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ENVIRONMENT")}.json", optional: true)
.AddEnvironmentVariables();
_config = builder.Build();
}
public string ComPort => _config["Device:ComPort"] ?? "COM1";
public int BaudRate => int.Parse(_config["Device:BaudRate"] ?? "9600");
public int Timeout => int.Parse(_config["Device:Timeout"] ?? "1000");
}
配置热重载
csharp复制// 使用IOptionsMonitor实现配置热更新
public class DeviceService : IDisposable
{
private readonly IOptionsMonitor<DeviceConfig> _config;
private IDisposable _changeToken;
public DeviceService(IOptionsMonitor<DeviceConfig> config)
{
_config = config;
_changeToken = _config.OnChange(ReloadConfig);
}
private void ReloadConfig(DeviceConfig newConfig)
{
// 重新初始化设备连接
}
public void Dispose()
{
_changeToken?.Dispose();
}
}
9.4 配置版本控制与迁移
csharp复制public class ConfigMigrator
{
public void Migrate(string configPath)
{
var config = LoadConfig(configPath);
// 检查配置版本
if(config.Version < CurrentVersion)
{
// 执行迁移逻辑
if(config.Version == 1)
{
// 从v1迁移到v2
config.NewProperty = "default";
config.Version = 2;
}
// 保存迁移后的配置
SaveConfig(config, configPath);
}
}
}
10. 缺乏完善的日志与诊断机制
10.1 工控系统的日志需求
- 运行日志:记录程序正常运行状态
- 错误日志:记录异常和错误情况
- 操作日志:记录用户关键操作
- 通信日志:记录与设备的原始通信数据
- 性能日志:记录系统资源使用情况
10.2 日志系统的设计原则
- 分级记录:不同级别(Debug, Info, Warning, Error)
- 结构化日志:便于后续分析
- 异步记录:不影响主程序性能
- 日志轮转:避免日志文件过大
- 敏感信息过滤:不记录密码等敏感信息
10.3 实现示例
csharp复制public static class Logger
{
private static readonly ILogger _logger;
static Logger()
{
_logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.WithThreadId()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.File("logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] <{ThreadId}> {Message}{NewLine}{Exception}")
.CreateLogger();
}
public static void LogDebug(string message) => _logger.Debug(message);
public static void LogInfo(string message) => _logger.Information(message);
public static void LogWarning(string message) => _logger.Warning(message);
public static void LogError(string message, Exception ex = null) => _logger.Error(ex, message);
public static IDisposable BeginScope(string context) => _logger.BeginScope(context);
}
10.4 诊断工具集成
csharp复制public class DiagnosticService
{
private readonly PerformanceCounter _cpuCounter;
private readonly PerformanceCounter _memCounter;
private Timer _monitorTimer;
public DiagnosticService()
{
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
_memCounter = new PerformanceCounter("Memory", "Available MBytes");
_monitorTimer = new Timer(LogSystemStatus, null,
TimeSpan.FromMinutes(1), // 初始延迟
TimeSpan.FromMinutes(5)); // 间隔
}
private void LogSystemStatus(object state)
{
var cpuUsage = _cpuCounter.NextValue();
var availableMem = _memCounter.NextValue();
Logger.LogInfo($"系统状态 - CPU: {cpuUsage}%, 可用内存: {availableMem}MB");
}
public void StartCommunicationLogging()
{
// 实现通信数据记录
}
}
11. 未考虑Windows服务化部署
11.1 工控程序的部署方式
- 控制台应用:开发调试方便,但不适合生产环境
- Windows服务:适合后台长期运行
- 系统托盘应用:需要用户交互的场景
11.2 创建Windows服务
csharp复制public class IndustrialService : ServiceBase
{
private DeviceMonitor _monitor;
public IndustrialService()
{
ServiceName = "IndustrialMonitor";
CanStop = true;
CanPauseAndContinue = true;
AutoLog = true;
}
protected override void OnStart(string[] args)
{
_monitor = new DeviceMonitor();
_monitor.Start();
}
protected override void OnStop()
{
_monitor?.Stop();
_monitor?.Dispose();
}
public static void Main(string[] args)
{
Run(new IndustrialService());
}
}
11.3 服务安装与配置
powershell复制# 使用sc命令安装服务
sc create "IndustrialMonitor" binPath="C:\path\to\your\service.exe" start=auto
# 启动服务
sc start IndustrialMonitor
# 停止服务
sc stop IndustrialMonitor
# 删除服务
sc delete IndustrialMonitor
11.4 服务与桌面交互
Windows服务默认无法直接与桌面交互,需要通过IPC机制:
csharp复制// 使用命名管道实现服务与UI通信
public class ServicePipeServer
{
private NamedPipeServerStream _pipe;
public void Start()
{
_pipe = new NamedPipeServerStream("IndustrialMonitorPipe", PipeDirection.InOut);
Task.Run(() => WaitForConnection());
}
private void WaitForConnection()
{
_pipe.WaitForConnection();
var reader = new StreamReader(_pipe);
var writer = new StreamWriter(_pipe);
while(_pipe.IsConnected)
{
var message = reader.ReadLine();
// 处理消息...
writer.WriteLine("Response");
writer.Flush();
}
}
}
12. 忽略系统DPI设置,导致界面显示异常
12.1 工控设备的显示多样性
工控设备可能使用:
- 高DPI的现代显示器
- 低DPI的老旧工业显示器
- 不同缩放比例的远程桌面
12.2 DPI感知配置
WinForm应用
csharp复制// 在Program.cs中启用DPI感知
[STAThread]
static void Main()
{
if(Environment.OSVersion.Version.Major >= 6)
SetProcessDPIAware();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetProcessDPIAware();
WPF应用
xml复制<!-- 在app.xaml中添加DPI感知声明 -->
<Application x:Class="YourApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<sys:Double x:Key="{x:Static SystemParameters.WindowGlassBrushThicknessKey}">0</sys:Double>
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
12.3 自适应布局技巧
WPF自适应布局
xml复制<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterScreen"
TextOptions.TextFormattingMode="Display"
SnapsToDevicePixels="True"
UseLayoutRounding="True">
<Grid>
<Viewbox>
<!-- 内容会自动缩放 -->
<Canvas Width="1920" Height="1080">
<!-- 设计时使用固定尺寸 -->
</Canvas>
</Viewbox>
</Grid>
</Window>
WinForm高DPI适配
csharp复制public class DpiAwareForm : Form
{
private float _dpiScale = 1.0f;
public DpiAwareForm()
{
this.AutoScaleMode = AutoScaleMode.Dpi;
using(var g = this.CreateGraphics())
{
_dpiScale = g.DpiX / 96f;
}
}
protected override void ScaleControl(SizeF factor, BoundsSpecified specified)
{
base.ScaleControl(factor, specified);
// 手动调整特定控件
if(someControl != null)
{
someControl.Font = new Font(someControl.Font.FontFamily,
someControl.Font.Size * _dpiScale);
}
}
}
13. 未实现合理的权限控制
13.1 工控系统的权限需求
- 操作权限:限制关键操作(如参数修改、设备控制)
- 数据权限:敏感数据的访问控制
- 审计跟踪:记录谁在什么时候做了什么
13.2 基于角色的访问控制
csharp复制public enum UserRole
{
Operator,
Engineer,
Administrator
}
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class AuthorizeRoleAttribute : Attribute
{
public UserRole RequiredRole { get; }
public AuthorizeRoleAttribute(UserRole role)
{
RequiredRole = role;
}
}
public class AuthService
{
public UserRole CurrentUserRole { get; private set; }
public bool Login(string username, string password)
{
// 验证逻辑...
// 设置CurrentUserRole
return true;
}
public bool CheckAccess(UserRole requiredRole)
{
return CurrentUserRole >= requiredRole;
}
}
13.3 权限检查实现
方法级权限控制
csharp复制public class DeviceController
{
private readonly AuthService _auth;
public DeviceController(AuthService auth)
{
_auth = auth;
}
[AuthorizeRole(UserRole.Engineer)]
public void ChangeDeviceSettings(Settings newSettings)
{
// 只有工程师及以上角色可以调用
}
}
UI元素权限控制
csharp复制public static class UIExtensions
{
public static void ApplyPermission(this Control control, UserRole requiredRole, AuthService auth)
{
control.Enabled = auth.CheckAccess(requiredRole);
control.Visible = auth.CheckAccess(requiredRole);
}
}
// 使用示例
btnSaveSettings.ApplyPermission(UserRole.Engineer, authService);
13.4 操作审计日志
csharp复制public class AuditService
{
public void LogOperation(string operation, string details)
{
var logEntry = new AuditLog
{
Timestamp = DateTime.UtcNow,
Username = GetCurrentUsername(),
Operation = operation,
Details = details,
IpAddress = GetClientIp()
};
SaveLog(logEntry);
}
[AuthorizeRole(UserRole.Administrator)]
public List<AuditLog> GetLogs(DateTime from, DateTime to)
{
return LoadLogs(from, to);
}
}
14. 忽略系统本地化需求
14.1 工控软件的多语言支持
- 界面语言:适应不同地区操作人员
- 数据格式:日期、数字、单位等
- 文化习惯:颜色、图标等文化敏感元素
14.2 资源文件管理
创建资源文件
code复制Resources/
Strings.resx (默认语言)
Strings.zh-CN.resx (中文)
Strings.de-DE.resx (德语)
WPF多语言实现
xml复制<!-- 在XAML中使用动态资源 -->
<TextBlock Text="{DynamicResource MainWindow_Title}" />
<!-- 切换语言 -->
public void SetCulture(string culture)
{
var newCulture = new CultureInfo(culture);
Thread.CurrentThread.CurrentCulture = newCulture;
Thread.CurrentThread.CurrentUICulture = newCulture;
// 更新所有窗口
foreach(Window window in Application.Current.Windows)
{
var oldDataContext = window.DataContext;
window.DataContext = null;
window.DataContext = oldDataContext;
}
}
WinForm多语言实现
csharp复制public static void ApplyLanguage(Form form, CultureInfo culture)
{
var resources = new ComponentResourceManager(form.GetType());
foreach(Control control in form.Controls)
{
resources.ApplyResources(control, control.Name, culture);
}
resources.ApplyResources(form, "$this", culture);
}
14.3 文化敏感的数据处理
csharp复制// 解析用户输入时考虑文化差异
public static decimal ParseDecimal(string input)
{
// 尝试当前文化
if(decimal.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out var result))
return result;
// 尝试不变文化
if(decimal.TryParse(input, NumberStyles.Any, CultureInfo.InvariantCulture, out result))
return result;
throw new FormatException("无效的数字格式");
}
// 格式化显示时使用特定文化
public static string FormatTemperature(decimal value, bool useMetric)
{
var format = useMetric ? "#,##0.0 °C" : "#,##0.0 °F";
return value.ToString(format, CultureInfo.CurrentCulture);
}
15. 缺乏自动化测试与持续集成
15.1 工控软件的测试挑战
- 硬件依赖:难以模拟所有设备
- 实时性要求:时序相关的bug难以复现
- 长期稳定性:内存泄漏等问题需要长时间测试
15.2 分层测试策略
- 单元测试:验证独立模块的功能
- 集成测试:测试模块间的交互
- 硬件在环测试:使用模拟器或真实设备测试
- 长期稳定性测试:持续运行测试用例
15.3 单元测试示例
csharp复制[TestClass]
public class DataParserTests
{
[TestMethod]
public void ParseIndustrialValue_ValidInput_ReturnsExpectedValue()
{
// 准备
var parser = new DataParser();
string input = " 123.45 mA ";
// 执行
bool success = parser.TryParse(input, out double value);
// 验证
Assert.IsTrue(success);
Assert.AreEqual(123.45, value, 0.001);
}
[TestMethod]
[DataRow("N/A")]
[DataRow("ERROR")]
[DataRow("")]
public void ParseIndustrialValue_InvalidInput_ReturnsFalse(string input)
{
var parser = new DataParser();
bool success = parser.TryParse(input, out _);
Assert.IsFalse(success);
}
}
15.4 硬件通信测试
csharp复制public class FakeDevice : IDevice
{
private Queue<string> _responseQueue = new Queue<string>();
public void EnqueueResponse(string response)
{
_responseQueue.Enqueue(response);
}
public string SendCommand(string command)
{
if(_responseQueue.Count == 0)
throw new TimeoutException("设备无响应");
return _responseQueue.Dequeue();
}
}
[TestClass]
public class DeviceControllerTests
{
[TestMethod]
public void ReadValue_WhenDeviceReturnsValidData_ReturnsParsedValue()
{
// 准备模拟设备
var fakeDevice = new FakeDevice();
fakeDevice.EnqueueResponse("123.45");
var controller = new DeviceController(fakeDevice);
// 执行
var result = controller.ReadValue();
// 验证
Assert.AreEqual(123.45, result, 0.001);
}
}
15.5 持续集成配置
yaml复制# Azure Pipelines示例
trigger:
- master
pool:
vmImage: 'windows-latest'
variables:
solution: '**/*.sln'
buildPlatform: 'Any CPU'
buildConfiguration: 'Release'
steps:
- task: NuGetToolInstaller@1
- task: NuGetCommand@2
inputs:
restoreSolution: '$(solution)'
- task: VSBuild@1
inputs:
solution: '$(solution)'
msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:DesktopBuildPackageLocation="$(build.artifactStagingDirectory)\WebApp.zip" /p:DeployIisAppPath="Default Web Site"'
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
- task: VSTest@2
inputs:
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
testSelector: 'testAssemblies'
testAssemblyVer2: |
**\*test*.dll
!**\*TestAdapter.dll
!**\obj\**
searchFolder: '$(System.DefaultWorkingDirectory)'
codeCoverageEnabled: true
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'
publishLocation: 'Container'