☰
.NET Framework下C#实现YAML与JSON互转实战
2026/10/3 6:42:11 网站建设 项目流程

1. 项目概述:为什么在.NET Framework窗体应用里折腾YAML和JSON转换

我在做工业上位机软件时,经常遇到客户现场的配置需求——他们不想要XML那种嵌套得让人头晕的结构,也不接受ini文件里一堆section和key-value的原始写法,更别提手写JSON时一个逗号放错位置就整个文件解析失败的尴尬。这时候YAML就自然浮出水面:缩进即层级、支持注释、天然可读性强,特别适合给非程序员的产线工程师或设备调试员修改参数。但问题来了,.NET Framework原生根本不认识YAML,System.Text.Json是.NET Core 3.0才有的东西,而我们大量存量项目还卡在.NET Framework 4.5甚至4.0上,连Newtonsoft.Json都得手动NuGet安装。所以这个项目不是“炫技”,而是真实产线里的生存刚需:用最轻量、最稳定、最不依赖新框架的方式,让老系统也能读写YAML,并且能无缝转成JSON供后续模块(比如对接Web API、日志上报、配置同步)使用。

核心关键词“c#”、“NET Framework”、“Yaml”、“json”不是并列关系,而是有明确技术栈约束的链条:必须基于.NET Framework(不是.NET Core/.NET 5+),必须用C#语言实现(不是VB.NET或F#),YAML操作不能靠外部CLI工具调用(那会引入进程启动开销和权限问题),JSON转换必须能双向互通(不只是YAML→JSON,还得能JSON→YAML回写)。我实测过三个主流YAML库在.NET Framework下的表现:YamlDotNet最成熟,SharpYaml对中文支持有坑,而LibYaml-CSharp根本编译不过Framework 4.0。最终选定YamlDotNet 4.3.2(注意不是最新版,新版已转向.NET Standard,老框架跑不动),搭配Newtonsoft.Json 12.0.3(兼容性最强的版本),整个方案零额外依赖、零运行时安装要求,打包进exe就能直接跑。这不是理论推演,而是我在三台不同Windows版本(Win7 SP1、Win10 LTSC、Win11 22H2)的工控机上反复验证过的路径。

2. 整体设计思路与技术选型逻辑

2.1 为什么死磕.NET Framework而不是升级到.NET Core

有人会说:“直接重写成.NET 6不就完了?”——这话在Demo环境里很响亮,但在真实产线里就是一句空话。我们去年交付的一个PLC数据采集系统,客户现场有27台工控机,其中19台装的是Windows Embedded Standard 7,连.NET Framework 4.5都不支持,最高只认4.0;另外8台是Win10 IoT Enterprise,管理员锁死了系统更新策略,禁止任何.NET Runtime变更。强行升级意味着要重新走客户IT部门的软硬件准入流程,平均耗时47个工作日,而客户要求的配置文件热更新功能上线 deadline 是15天。所以技术选型的第一条铁律是:向下兼容性优先于技术先进性。YamlDotNet 4.x系列明确标注支持.NET Framework 2.0+,Newtonsoft.Json 12.x支持Framework 2.0~4.8全版本,这两个库加起来不到800KB,静态链接进项目后,连GAC注册都不需要,这才是工业场景的正确打开方式。

2.2 YAML解析器为何不选SharpYaml或YamlStream

SharpYaml的问题在于它的类型映射机制太“激进”。比如你定义一个类public class DeviceConfig { public string Name { get; set; } public int Timeout { get; set; } },当YAML里写timeout: 30s(带单位字符串),它会直接抛InvalidCastException,而YamlDotNet默认把未知字段当string处理,允许你在反序列化后手动做单位转换。更致命的是,SharpYaml对BOM头(Byte Order Mark)处理有bug,Windows记事本保存的UTF-8文件自带BOM,它会把第一个字段名解析成name,导致所有属性绑定失败。YamlStream则过于轻量,连基本的锚点(anchor)和别名(alias)都不支持,而客户提供的设备模板YAML里大量使用&common和*common来复用配置块。YamlDotNet 4.3.2的DeserializerBuilder.WithNamingConvention(new NullNamingConvention())可以关闭驼峰转换,IgnoreUnmatchedProperties()能跳过YAML里多出来的字段,这些细节能让你少掉一半头发。

2.3 JSON转换为何不用System.Text.Json而坚持Newtonsoft.Json

System.Text.Json在.NET Framework下根本不存在——它是.NET Core 3.0的产物。即使你强行通过NuGet安装System.Text.Json包(6.0.0版本),它在Framework 4.6.1以下会报Could not load file or assembly 'System.Runtime.CompilerServices.Unsafe',因为这个Assembly在旧Framework里是内置的,新版包却试图覆盖它。Newtonsoft.Json 12.0.3则经过了十年打磨,对循环引用、DateTime格式、$type元数据等边缘情况处理得极其稳健。举个实际例子:客户YAML里有一段last_update: 2024-03-15T14:22:33.123+08:00,YamlDotNet反序列化出来是DateTimeOffset类型,Newtonsoft.Json默认能正确序列化成ISO8601格式的字符串;但如果你用早期版本(如9.x),它会把offset丢掉变成"2024-03-15T14:22:33.123",导致时区信息丢失。12.0.3的JsonConvert.SerializeObject(obj, new JsonSerializerSettings { DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind })能完美保留时区,这才是工业系统要的精度。

2.4 窗体应用特有的线程安全与UI响应设计

WinForms是单线程 Apartment(STA)模型,所有UI控件只能由创建它的线程访问。但YAML文件读写是IO密集型操作,如果直接在按钮点击事件里File.ReadAllText()再YamlStream.Load(),大文件(>5MB)会导致界面卡死10秒以上。我的做法是:用BackgroundWorker封装整个YAML/JSON转换流程,ProgressChanged事件更新进度条,RunWorkerCompleted里用this.Invoke()安全更新UI。关键细节在于——不要在后台线程里直接操作控件属性。比如你想把解析后的JSON显示在TextBox里,不能写textBox1.Text = jsonStr,而要写:

this.Invoke((MethodInvoker)delegate { textBox1.Text = jsonStr; textBox1.SelectionStart = 0; });

否则会触发InvalidOperationException: Cross-thread operation not valid。这个坑我踩过三次,第一次以为是YAML库问题,重装了五遍NuGet包才发现是线程模型没搞清。

3. 核心细节解析与实操要点

3.1 YamlDotNet的正确安装与引用配置

在Visual Studio 2019中,右键项目→“管理NuGet程序包”→切换到“联机”源→搜索YamlDotNet→选择版本4.3.2(不是5.x或6.x!)→安装。安装完成后,检查项目文件.csproj里是否生成了这行:

<PackageReference Include="YamlDotNet" Version="4.3.2" />

如果是老式packages.config方式,则确保packages.config里有:

<package id="YamlDotNet" version="4.3.2" targetFramework="net45" />

重点来了:必须手动添加using别名。因为YamlDotNet和Newtonsoft.Json都有JsonSerializer类,不加别名会编译报错。在主窗体.cs文件顶部写:

using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.RepresentationModel; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.IO;

然后在类内部声明序列化器时用完整命名空间:

private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder() .WithNamingConvention(new NullNamingConvention()) .IgnoreUnmatchedProperties() .Build(); private readonly ISerializer _yamlSerializer = new SerializerBuilder() .WithNamingConvention(new NullNamingConvention()) .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) .Build();

这里NullNamingConvention是关键——它禁用驼峰转换,让YAML里的device_name直接映射到C#属性DeviceName,而不是强制转成deviceName(后者在Framework老版本里可能触发反射异常)。

3.2 YAML文件读取的容错处理实战

真实产线YAML文件从来不是教科书式的标准格式。我遇到过最离谱的情况是:客户用Excel导出CSV再用在线工具转YAML,结果生成了- { name: "Motor1", type: "servo", config: { p: 1.2, i: 0.3, d: 0.05 } }这种混合风格,YamlDotNet默认解析会失败。解决方案分三层:

第一层是文件编码检测。Windows记事本存的UTF-8带BOM,Notepad++存的UTF-8无BOM,ANSI编码(其实是GBK)更常见。不能简单用File.ReadAllText(path),而要用:

private static string ReadYamlFile(string path) { var bytes = File.ReadAllBytes(path); if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); // 跳过BOM if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return Encoding.Unicode.GetString(bytes); // UTF-16 LE return Encoding.Default.GetString(bytes); // 系统默认编码(通常是GBK) }

第二层是语法校验。YAML语法错误(比如缩进不一致、冒号后少空格)会导致YamlException。捕获后不能直接弹窗“文件格式错误”,而要定位到具体行号:

try { var yamlStr = ReadYamlFile(filePath); var input = new StringReader(yamlStr); var deserializer = new DeserializerBuilder().Build(); var obj = deserializer.Deserialize<Dictionary<string, object>>(input); } catch (YamlException ex) { MessageBox.Show($"YAML解析失败,第{ex.Start.Line}行:{ex.Message}", "配置错误"); }

第三层是数据结构适配。客户给的YAML可能是纯字典(key: value),也可能是列表(- item1\n- item2),还可能是混合结构。我封装了一个通用解析方法:

public static object ParseYaml(string yamlContent) { try { var input = new StringReader(yamlContent); var deserializer = new DeserializerBuilder() .WithNamingConvention(new NullNamingConvention()) .IgnoreUnmatchedProperties() .Build(); return deserializer.Deserialize<object>(input); } catch { return null; // 解析失败返回null,由调用方决定如何处理 } }

返回object类型,后续用JToken.FromObject()转成JObject,就能用LINQ to JSON灵活查询了。

3.3 JSON转换中的类型陷阱与精度控制

YAML到JSON转换最隐蔽的坑是数字精度丢失。YAML规范里3.14159265358979323846这种长浮点数,默认会被YamlDotNet解析成double,而double只有15-17位有效数字,后面全变0。客户设备参数要求保留20位小数,怎么办?答案是:强制用decimal类型反序列化。YamlDotNet支持自定义类型转换器:

public class DecimalTypeConverter : INodeTypeResolver { public bool TryResolve(IParser parser, ref INodeTypeResolverResult result) { if (parser.Current is Scalar scalar && decimal.TryParse(scalar.Value, out _)) { result = new NodeTypeResolverResult(typeof(decimal)); return true; } return false; } } // 使用时: var deserializer = new DeserializerBuilder() .WithTypeConverter(new DecimalTypeConverter()) .Build();

这样timeout: 0.0001234567890123456789就能完整保留为decimal,再用Newtonsoft.Json序列化时,JsonConvert.SerializeObject(obj, Formatting.None, new JsonSerializerSettings { FloatParseHandling = FloatParseHandling.Decimal })确保不转成科学计数法。

另一个坑是日期时间格式混乱。YAML里last_modified: 2024-03-15和last_modified: 2024-03-15T14:22:33Z会被解析成不同类型的DateTime,前者是DateOnly(.NET 6才有),后者是DateTime。在Framework下统一用DateTime.ParseExact()处理:

private static DateTime SafeParseDateTime(string str) { var formats = new[] { "yyyy-MM-dd", "yyyy-MM-ddTHH:mm:ssZ", "yyyy-MM-ddTHH:mm:ss.fffZ", "yyyy-MM-ddTHH:mm:sszzz" }; return DateTime.TryParseExact(str, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dt) ? dt : DateTime.Now; }

3.4 窗体控件与配置数据的双向绑定技巧

WinForms没有WPF那样的MVVM绑定,但可以用BindingSource实现伪双向绑定。比如配置界面有个NumericUpDown控件对应YAML里的max_retries字段:

// 定义配置类 public class AppConfig { public int MaxRetries { get; set; } = 3; public string ServerIp { get; set; } = "192.168.1.100"; public List<Device> Devices { get; set; } = new List<Device>(); } // 在窗体Load事件里 private void Form1_Load(object sender, EventArgs e) { _config = LoadConfig(); // 从YAML加载 bindingSource.DataSource = _config; numericUpDown1.DataBindings.Add("Value", bindingSource, "MaxRetries", false, DataSourceUpdateMode.OnPropertyChanged); textBox1.DataBindings.Add("Text", bindingSource, "ServerIp", false, DataSourceUpdateMode.OnPropertyChanged); }

关键点在于DataSourceUpdateMode.OnPropertyChanged——它让控件值改变时立即更新_config对象,而不是等到焦点离开才更新。这样用户改完IP点“保存”按钮时,_config.ServerIp已经是最新值。但要注意:List<T>绑定到DataGridView时,必须用BindingList<T>才能实时响应增删,否则改了列表内容UI不会刷新。

4. 实操过程与核心环节实现

4.1 创建YAML配置文件的标准化模板

客户给的YAML往往结构混乱,我制定了三类模板规范:

基础配置模板(app.config.yaml):

# 设备通信参数 com_port: "COM3" baud_rate: 115200 timeout_ms: 500 # 数据采集周期 scan_interval_sec: 2.5 max_history_count: 1000 # 日志级别 log_level: "INFO" log_path: "C:\\Logs\\"

设备列表模板(devices.yaml):

- name: "Motor_A" type: "stepper" address: 0x01 config: steps_per_rev: 200 microstep: 16 - name: "Sensor_B" type: "temperature" address: 0x02 config: range_min: -40.0 range_max: 125.0

高级模板(含锚点复用):

common_settings: &common timeout_ms: 300 retry_count: 3 motor_config: <<: *common steps_per_rev: 400 sensor_config: <<: *common sample_rate_hz: 10

生成模板的代码要带BOM头(避免记事本乱码):

private void CreateDefaultYaml(string path) { var template = @"# 自动生成的配置模板 com_port: ""COM1"" baud_rate: 9600 timeout_ms: 500"; File.WriteAllText(path, template, new UTF8Encoding(true)); // true表示写入BOM }

4.2 YAML读取与JSON转换的完整代码链

以下是按钮点击事件的完整实现,包含异常处理和进度反馈:

private void btnLoadYaml_Click(object sender, EventArgs e) { using (var ofd = new OpenFileDialog()) { ofd.Filter = "YAML files|*.yaml;*.yml|All files|*.*"; if (ofd.ShowDialog() != DialogResult.OK) return; backgroundWorker.RunWorkerAsync(ofd.FileName); } } private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { var filePath = e.Argument as string; var progress = (BackgroundWorker)sender; try { // 步骤1:读取文件(含编码检测) progress.ReportProgress(10, "正在读取文件..."); var yamlContent = ReadYamlFile(filePath); // 步骤2:YAML解析(含语法校验) progress.ReportProgress(30, "正在解析YAML..."); var yamlObj = ParseYaml(yamlContent); if (yamlObj == null) { e.Result = "YAML解析失败"; return; } // 步骤3:转JSON字符串(含精度控制) progress.ReportProgress(60, "正在转换JSON..."); var jsonStr = JsonConvert.SerializeObject(yamlObj, Formatting.Indented, new JsonSerializerSettings { FloatParseHandling = FloatParseHandling.Decimal, DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind }); // 步骤4:写入临时文件供调试 progress.ReportProgress(90, "正在保存临时JSON..."); File.WriteAllText(Path.ChangeExtension(filePath, ".json"), jsonStr, Encoding.UTF8); e.Result = jsonStr; } catch (Exception ex) { e.Result = $"错误:{ex.Message}"; } } private void backgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e) { toolStripStatusLabel1.Text = e.UserState as string; progressBar1.Value = e.ProgressPercentage; } private void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if (e.Error != null) { MessageBox.Show($"后台任务异常:{e.Error.Message}"); return; } if (e.Result is string result && !result.StartsWith("错误:")) { this.Invoke((MethodInvoker)delegate { textBoxJson.Text = result; tabControl1.SelectedIndex = 1; // 切换到JSON标签页 }); } else { MessageBox.Show(e.Result as string, "操作失败"); } }

4.3 JSON转YAML的逆向工程实现

客户有时需要把Web端返回的JSON配置存回本地YAML。关键点在于:JSON的{}对象转YAML要保持键顺序(JSON本身无序,但YAML习惯按业务逻辑排序),[]数组要转成YAML列表格式:

public static string JsonToYaml(string jsonString) { try { var jObj = JObject.Parse(jsonString); var serializer = new SerializerBuilder() .WithNamingConvention(new NullNamingConvention()) .EmitDefaults() .Build(); // 手动排序键以保证YAML可读性 var orderedObj = new JObject(); foreach (var key in jObj.Properties().OrderBy(p => p.Name)) { orderedObj[key.Name] = key.Value; } using (var writer = new StringWriter()) { serializer.Serialize(writer, orderedObj.ToObject<object>()); return writer.ToString(); } } catch (Exception ex) { throw new InvalidOperationException($"JSON转YAML失败:{ex.Message}"); } }

测试用例:输入{"server":"192.168.1.100","port":8080,"timeout":500},输出:

server: "192.168.1.100" port: 8080 timeout: 500

注意引号自动添加——YamlDotNet默认对字符串加双引号,避免true/false被误解析为布尔值。

4.4 配置文件热更新监控机制

工业系统要求配置修改后无需重启。用FileSystemWatcher监听YAML文件变化:

private FileSystemWatcher _watcher; private void StartWatchYaml(string path) { _watcher = new FileSystemWatcher(); _watcher.Path = Path.GetDirectoryName(path); _watcher.Filter = Path.GetFileName(path); _watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime; _watcher.Changed += OnYamlChanged; _watcher.EnableRaisingEvents = true; } private void OnYamlChanged(object source, FileSystemEventArgs e) { // 防抖:避免连续写入触发多次 if (_lastChangeTime.AddMilliseconds(500) > DateTime.Now) return; _lastChangeTime = DateTime.Now; try { var newConfig = LoadConfig(); // 重新加载 // 更新内存中的配置对象 _currentConfig = newConfig; // 通知其他模块(用事件或委托) ConfigChanged?.Invoke(this, newConfig); } catch (Exception ex) { // 记录错误但不中断监控 LogError($"配置热更新失败:{ex.Message}"); } }

这里_lastChangeTime防抖是必须的——Windows记事本保存文件时会先写临时文件再重命名,触发两次Changed事件,不加防抖会导致配置加载两次。

5. 常见问题与排查技巧实录

5.1 典型问题速查表

问题现象根本原因解决方案
YamlException: Anchor not foundYAML里用了*common但没定义&common用正则^\s*&\w+检查文件开头是否有锚点定义,或改用<<: { timeout_ms: 300 }内联字典
JsonSerializationException: Self referencing loop detected对象里有循环引用(A→B→A)JsonConvert.SerializeObject(obj, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore })
InvalidCastException: Cannot cast from source type to destination typeYAML字段类型与C#属性类型不匹配(如string→int)在属性上加[YamlMember(AppliesTo = typeof(int))]或改用object类型再手动转换
System.IO.IOException: The process cannot access the file because it is being used by another process文件被其他程序占用(如记事本未关闭)用try { File.OpenRead(path).Close(); } catch { MessageBox.Show("文件被占用,请关闭编辑器"); }预检
Newtonsoft.Json.JsonReaderException: Unexpected character encountered while parsing valueJSON字符串含不可见字符(如U+200B零宽空格)jsonString = Regex.Replace(jsonString, @"[\u200B-\u200D\uFEFF]", "")清洗

5.2 生产环境必做的三项加固

第一项:文件锁定保护
YAML读写时用FileStream显式指定共享模式,避免多进程冲突:

// 读取时允许其他进程读 using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096)) using (var reader = new StreamReader(fs, Encoding.UTF8)) { var content = reader.ReadToEnd(); } // 写入时独占锁定 using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096)) using (var writer = new StreamWriter(fs, Encoding.UTF8)) { writer.Write(yamlContent); }

第二项:配置校验钩子
在LoadConfig()后插入业务规则校验:

private bool ValidateConfig(AppConfig config) { if (string.IsNullOrWhiteSpace(config.ServerIp)) return ShowError("服务器IP不能为空"); if (config.MaxRetries < 1 || config.MaxRetries > 10) return ShowError("重试次数必须在1-10之间"); return true; }

第三项:降级策略
当YAML解析失败时,自动回退到内置默认配置:

public AppConfig LoadConfig() { var path = "app.config.yaml"; if (File.Exists(path)) { try { var yaml = ReadYamlFile(path); return _yamlDeserializer.Deserialize<AppConfig>(new StringReader(yaml)); } catch { // 解析失败,返回硬编码默认值 return new AppConfig { ServerIp = "127.0.0.1", MaxRetries = 3 }; } } return new AppConfig(); // 默认构造函数值 }

5.3 我踩过的五个深坑及避坑口诀

坑1:YAML注释被当成数据
客户在YAML里写# 这是注释,YamlDotNet 4.3.2默认会把注释行当字符串值。解决方案:升级到4.8.0(仍兼容Framework),或用DeserializerBuilder().WithEventEmitter(...)过滤注释事件。

坑2:中文路径乱码
File.ReadAllText("C:\\配置\\设备.yaml")在Framework 4.0下会报DirectoryNotFoundException。根本原因是.NET Framework对Unicode路径支持不完善。解决口诀:永远用Path.GetFullPath()规范化路径,且确保项目属性里“目标平台”设为x64(x86下Unicode路径支持更差)。

坑3:Newtonsoft.Json序列化DateTime为13位时间戳
客户要求JSON里"last_update": 1710512553123(毫秒时间戳),但默认是字符串。口诀:用IsoDateTimeConverter并设置DateTimeFormat = "U",或自定义JsonConverter继承DateTimeConverterBase。

坑4:YAML列表解析成JArray后无法Bind到DataGridView
var list = JArray.Parse(json)得到的对象不能直接dataGridView1.DataSource = list。口诀:必须转成List<T>,用list.ToObject<List<Device>>(),或用BindingList<T>包装。

坑5:BackgroundWorker在窗体关闭时崩溃
用户点X关闭窗体,后台还在跑RunWorkerAsync,this.Invoke会抛ObjectDisposedException。口诀:在FormClosing事件里调用backgroundWorker.CancelAsync(),并在DoWork里用if (worker.CancellationPending) return;提前退出。

最后分享个小技巧:YAML文件编辑推荐用VS Code + Red Hat YAML插件,它能实时语法检查、缩进对齐、Schema校验(可关联JSON Schema),比记事本靠谱十倍。而生产环境部署时,把YamlDotNet.dll和Newtonsoft.Json.dll直接复制到exe同目录,用ILMerge合并成单文件——这样客户双击就能用,再也不用问“为啥要装.NET Framework 4.5”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询