大家好!我们要深入探讨一个非常常用的技术:JSON反序列化。别小看这个技术,它可是现代编程中不可或缺的一部。JSON解析不仅仅是简单的数据转换,它还涉及到复杂的词法分析和文法解析。这些技术是编译器设计的基础,但这不是我们今天要深入探讨的内容。
我们想通过一些简化的方法和直觉的思考,以纯c#代码为例,分享实现自己的可自定义的JSON解析器的过程,希望大家可以更好地理解数据结构和算法,提升编程能力。
文中完整的代码和项目,已基于MIT协议开源。你可以自由地使用、修改和分发。你可以根据自己的需求进行定制,随意集成。所以,让我们开始吧。
一、先来认识一下JSON
1.1 什么是JSON?
JSON就像是一种"数据语言",用来在不同的程序之间传递信息。比如:
{ "name": "小明", "age": 20, "isStudent": true, "hobbies": ["篮球", "音乐", "编程"] }
你看,这就是一个JSON对象,它很像我们C#中的类. 但JSON里面的取值只有几种有限的基础类型:
string:就是文本,比如"你好"; number:数字,比如123, -1.2, 1E-4; bool:布尔值true或false; array:包含多个取值的集合 map:包含多个键值对的集合
很清楚的能看到,用什么语言解析json第一步是需要将json中的取值映射到该语言下的对应类型中. 对于c#, 我们仅需要考虑几个简单类型即可:
string对应text in C#
double,int对应number in C#
bool对应bool in C#
array对应 List<object> in C#
map对应Dictionary<string,object>in C#
1.2 为什么要反序列化?
- 想象一下:你的朋友用微信给你发了一条消息,这条消息需要从"网络格式"转换成你能看懂的文字。JSON反序列化就是做类似的事情.
- 另外就是关于数据的存储,因为复杂的结构化数据不能一直放在内存中,当要进入磁盘持久化时,可以选择将对象存储为JSon,清晰易读,现在很多程序的配置文件都是这么用的.
1.3 为什么要尝试单独实现?
很多自定义的场景,包括但不限于:
- 特殊场景的极限性能考虑,轻量级无需反射
- 特殊注释的实现 ( 根据JSON标准(RFC 8259),JSON格式不支持注释。这就是为什么很多严格的JSON解析器遇到注释会报错。)
- 无需考虑源生成,简单场景直接AOT编译
- 高度频繁修改的对象, 无需修改映射的实体对象.
二、解析主流程
整体流程就像拆快递包裹:拿到大包裹 -> 拆开大包裹 -> 如果大包裹里有小包裹 -> 再拆开小包裹
开始拆快递(Parse方法) ↓ 拿出小刀准备开箱(创建JsonReader) ↓ 判断里面是什么(ReadValue方法) ↓ 根据包装形状决定怎么拆: 📦 如果是方盒子{ } → 拆对象(ReadObject) 📦 如果是长盒子[ ] → 拆数组(ReadArray) 📦 如果是带""的 → 拆字符串(ReadString) 📦 如果是数字 → 拆数字(ReadNumber) 📦 如果是true/false → 拆布尔值(ReadBoolean) 📦 如果是null → 拆空盒子(ReadNull) ↓ 把所有东西整理好 ↓ 交给用户(返回结果)
我们暂时用List<object>,Dictionary<string,object>两个对象来描述json. 如1.1中所示的Json可以表现为:
Dictionary<string, object> json = new Dictionary<string, object>() { { "name","小明" }, { "age",20 }, {"isStudent", true}, {"hobbies", new List<object>{"篮球", "音乐", "编程" } } };
是不是很简单呢?
类的整体架构如下:
LumJsonDeserializer │ └── JsonReader (ref struct) ├── ReadValue() // 读取任意值 ├── ReadObject() // 读取对象 ├── ReadArray() // 读取数组 ├── ReadString() // 读取字符串 ├── ReadNumber() // 读取数字 ├── ReadBoolean() // 读取布尔值 └── ReadNull() // 读取null
三、解析的具体实现
具体入口如下:
public static object? Parse(string json) { var reader = new JsonReader(json); return reader.ReadValue(); } private ref struct JsonReader { public object? ReadValue() { SkipWhitespaceAndComments(); // 跳过空白和注释 var current = _span[_position]; return current switch { '{' => ReadObject(), // 对象 '[' => ReadArray(), // 数组 '"' => ReadString(), // 字符串 't' or 'f' => ReadBoolean(), // 布尔值 'n' => ReadNull(), // null _ when IsDigit(current) || current == '-' => ReadNumber(), // 数字 '/' => ThrowUnexpectedComment(), // 意外注释 _ => ThrowUnexpectedCharacter(current) // 意外字符 }; } }
ReadValue()作为总入口,根据当前字符类型分发到具体的读取方法,即核心分发器.
3.1 读取JSON对象,以ReadObject()为例
ReadObject() → ReadString() → ReadValue() → (递归)
ReadObject()最终将创建 Dictionary<string, object?>对象,他主要母的是读取键值对,键必须是字符串.
private Dictionary<string, object?> ReadObject() { // 创建一个新的字典来存储解析后的键值对 var obj = new Dictionary<string, object?>(); _position++; // 跳过对象开始的大括号 '{' SkipWhitespaceAndComments(); // 跳过可能的空白字符和注释 // 检查是否立即遇到结束大括号 '}'(空对象情况) if (TryConsume('}')) return obj; // 如果是空对象,直接返回空字典 // 开始循环处理对象中的每个键值对 while (true) { SkipWhitespaceAndComments(); // 跳过键之前的空白字符和注释 // 验证当前位置是否是双引号(JSON键必须是字符串) if (_span[_position] != '"') ThrowFormatException("Expected string key in object"); // 如果不是双引号,抛出格式异常 var key = ReadString(); // 读取键的字符串值 SkipWhitespaceAndComments(); // 跳过键后面的空白字符和注释 Consume(':'); // 消费并验证冒号分隔符,如果没有找到则抛出异常 SkipWhitespaceAndComments(); // 跳过冒号后面的空白字符和注释 var value = ReadValue(); // 读取值(可以是任何JSON类型:字符串、数字、布尔值、对象、数组等) obj[key] = value; // 将键值对添加到字典中 SkipWhitespaceAndComments(); // 跳过值后面的空白字符和注释 // 检查是否遇到对象结束的大括号 '}' if (TryConsume('}')) break; // 如果找到结束大括号,跳出循环 Consume(','); // 消费并验证逗号分隔符,用于分隔多个键值对 SkipWhitespaceAndComments(); // 跳过逗号后面的空白字符和注释 } return obj; // 返回解析完成的字典 }
当需要读取json模式中的值对象时, 这个方法会再次递归调用ReadValue().是不是非常简单?
当然我们除了ReadObject(), 还有ReadValue(), ReadObject(),ReadArray(),ReadString(),ReadNumber(),ReadBoolean(),ReadNull()都需要一一实现, 具体可自行查看代码.
3.2 辅助方法.
SkipWhitespaceAndComments()- 跳过空白和注释
private void SkipWhitespaceAndComments() { while (_position < _span.Length) { var current = _span[_position]; if (char.IsWhiteSpace(current)) { _position++; } else if (current == '/' && _position + 1 < _span.Length) { var next = _span[_position + 1]; if (next == '/') { SkipSingleLineComment(); //跳过单行注释 } else if (next == '*') { SkipMultiLineComment(); //跳过多行注释块 } else { break; } } else { break; } } }
TryConsume- 处理掉预期的字符如",)和].
比如当处于字符串中时
private bool TryConsume(char expected) { SkipWhitespaceAndComments(); if (_position < _span.Length && _span[_position] == expected) { _position++; return true; } return false; }
四 转义及特殊字符处理
4.1 转义字符
转义字符指的是当json的字符串值对象中含有的特殊含义的字符串,常见的比如有字符串 {"name:":""萤火"初芒"}, 读取出来的字符串应该是含有引号的 "萤火"初芒。但是字符串总中的引号会干扰正常解析流程,造成程序误以为提前引号对提前关闭而出错。
因此需要单独针对转义符号进行处理。具体方法是,当字符串解析过程ReadString()中,如果遇到转义符号时,暂不处理,提前跳过标记。
private string ReadString() { _position++; // 跳过 '"' int start = _position; int length = 0; bool hasEscapes = false; // 第一遍:计算长度和检测转义字符 while (_position < _span.Length) { var current = _span[_position]; if (current == '"') break; if (current == '\') //识别到了转义符号标记 { hasEscapes = true; _position++; // 跳过转义字符 length++; // 跳过转义字符 if (_position >= _span.Length) break; } _position++; length++; } if (_position >= _span.Length || _span[_position] != '"') ThrowFormatException("Unterminated string"); var resultSpan = _span.Slice(start, length); _position++; // 跳过结尾的 '"' if (!hasEscapes) return new string(resultSpan); return ProcessStringWithEscapes(resultSpan); //转义字符替换 }
在ProcessStringWithEscapes()方法中,处理的转义符号主要有以下集中:
'"' => '"', //引号 '\' => '\', //斜杠 '/' => '/', //斜杠 'b' => 'b', 'f' => 'f', 'n' => 'n', //换行 'r' => 'r', //换行 't' => 't', 'u' => ProcessUnicodeEscape(span, ref spanIndex), //处理unicode字符,u8424u706bu521du8292 -> 萤火初芒
4.2 数字处理
数字的处理比较简单,可以用库去实现,单这里列出了逐字符解析数字的过程。考虑了负数、小数点、科学计数等。
为了更好的展示自定义的功能,我们加入了对特殊数字表达的解析,如{"name:":.9527}。这样有一个好处,就是存储记录的时候省去了开头的一个0。一般的通用标准库是不支持对纯小数点开头的值.9527解析的。具体代码如下:
private object ReadNumber() { int start = _position; if (TryConsume('-')) start = _position; bool isDouble = _span[_position] == '.'; if (isDouble) { _position++;} // 快速扫描数字 while (_position < _span.Length && IsDigit(_span[_position])) _position++; if (_position < _span.Length && _span[_position] == '.') { if (isDouble) { ThrowFormatException("Invalid number format"); } isDouble = true; _position++; while (_position < _span.Length && IsDigit(_span[_position])) _position++; } if (_position < _span.Length && (_span[_position] == 'e' || _span[_position] == 'E')) { isDouble = true; _position++; if (_position < _span.Length && (_span[_position] == '+' || _span[_position] == '-')) _position++; while (_position < _span.Length && IsDigit(_span[_position])) _position++; } var numberSpan = _span.Slice(start, _position - start); // 方案1:优先尝试解析为整数 if (!isDouble && TryParseInteger(numberSpan, out long intValue)) return intValue; // 方案2:使用 double.TryParse(优化版) if (TryParseDouble(numberSpan, out double doubleValue)) return doubleValue; ThrowFormatException("Invalid number format"); return 0; }
五、最后
我们用c#完整实现了一个Json转换的单文件类,无反射,纯字符解析,完美支持aot。基于该json解析类,基于这个类,我们开发了一个简单读取修改保存的配置文件的库,简单的使用示例如下,可配置应用与任何场景,无需提前定义实体类映射:
// Create LumConfigManager config = new LumConfigManager(); config.Set("findmax", "xx"); config.Set("HotKey", 46); config.Set("Now", DateTime.Now); config.Set("TheHotKeys", new int[] { 46, 33, 21 }); config.Set("HotKeys:Mainkey", 426); // Nested configuration config.Save("d:\aa.json"); // Read existed file LumConfigManager loadedConfig = new LumConfigManager("d:\aa.json"); Console.WriteLine(loadedConfig.GetInt("HotKeys:Mainkey")); Console.WriteLine(loadedConfig.Get("Now")); var hotkeys = loadedConfig.Get("TheHotKeys") as IList; foreach (var key in hotkeys) { Console.WriteLine(key); }
保存的json文件如下:
{"findmax":"xx","HotKey":46,"Now":"2025/9/11 10:25:50","TheHotKeys":[46,33,21],"HotKeys":{"Mainkey":426}}
如果你对这款工具有任何建议或想法,欢迎随时交流!项目已在 GitHub 完全开源(MIT License),如果你觉得有用,欢迎点个 Star ⭐️支持一下! https://github.com/LdotJdot/LumConfig
可以关注微信公众号,更多想法更多内容欢迎交流!
