大家好!我们要深入探讨一个非常常用的技术:JSON反序列化。别小看这个技术,它可是现代编程中不可或缺的一部。JSON解析不仅仅是简单的数据转换,它还涉及到复杂的词法分析和文法解析。这些技术是编译器设计的基础,但这不是我们今天要深入探讨的内容。
我们想通过一些简化的方法和直觉的思考,以纯c#代码为例,分享实现自己的可自定义的JSON解析器的过程,希望大家可以更好地理解数据结构和算法,提升编程能力。
文中完整的代码和项目,已基于MIT协议开源。你可以自由地使用、修改和分发。你可以根据自己的需求进行定制,随意集成。所以,让我们开始吧。
一、先来认识一下JSON
1.1 什么是JSON?
JSON就像是一种"数据语言",用来在不同的程序之间传递信息。比如:
json
{
"name": "小明",
"age": 20,
"isStudent": true,
"hobbies": ["篮球", "音乐", "编程"]
}
你看,这就是一个JSON对象,它很像我们C#中的类. 但JSON里面的取值只有几种有限的基础类型:
text
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编译
- 高度频繁修改的对象, 无需修改映射的实体对象.
二、解析主流程
整体流程就像拆快递包裹:拿到大包裹 -> 拆开大包裹 -> 如果大包裹里有小包裹 -> 再拆开小包裹
text
开始拆快递(Parse方法)
↓
拿出小刀准备开箱(创建JsonReader)
↓
判断里面是什么(ReadValue方法)
↓
根据包装形状决定怎么拆:
📦 如果是方盒子{ } → 拆对象(ReadObject)
📦 如果是长盒子[ ] → 拆数组(ReadArray)
📦 如果是带""的 → 拆字符串(ReadString)
📦 如果是数字 → 拆数字(ReadNumber)
📦 如果是true/false → 拆布尔值(ReadBoolean)
📦 如果是null → 拆空盒子(ReadNull)
↓
把所有东西整理好
↓
交给用户(返回结果)
我们暂时用List<object>
,Dictionary<string,object>
两个对象来描述json. 如1.1中所示的Json可以表现为:
csharp
Dictionary<string, object> json = new Dictionary<string, object>()
{
{ "name","小明" },
{ "age",20 },
{"isStudent", true},
{"hobbies", new List<object>{"篮球", "音乐", "编程" } }
};
是不是很简单呢?
类的整体架构如下:
csharp
LumJsonDeserializer
│
└── JsonReader (ref struct)
├── ReadValue() // 读取任意值
├── ReadObject() // 读取对象
├── ReadArray() // 读取数组
├── ReadString() // 读取字符串
├── ReadNumber() // 读取数字
├── ReadBoolean() // 读取布尔值
└── ReadNull() // 读取null
三、解析的具体实现
具体入口如下:
csharp
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()为例
text
ReadObject() → ReadString() → ReadValue() → (递归)
ReadObject()最终将创建 Dictionary<string, object?>对象,他主要母的是读取键值对,键必须是字符串.
csharp
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()
- 跳过空白和注释
csharp
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
- 处理掉预期的字符如"
,)
和]
.
比如当处于字符串中时
charp
private bool TryConsume(char expected)
{
SkipWhitespaceAndComments();
if (_position < _span.Length && _span[_position] == expected)
{
_position++;
return true;
}
return false;
}
四 转义及特殊字符处理
4.1 转义字符
转义字符指的是当json的字符串值对象中含有的特殊含义的字符串,常见的比如有字符串 {"name:":"\"萤火\"初芒"}
, 读取出来的字符串应该是含有引号的 "萤火"初芒
。但是字符串总中的引号会干扰正常解析流程,造成程序误以为提前引号对提前关闭而出错。
因此需要单独针对转义符号\
进行处理。具体方法是,当字符串解析过程ReadString()
中,如果遇到转义符号\
时,暂不处理,提前跳过标记。
csharp
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()
方法中,处理的转义符号主要有以下集中:
csharp
'"' => '"', //引号
'\\' => '\\', //斜杠
'/' => '/', //斜杠
'b' => '\b',
'f' => '\f',
'n' => '\n', //换行
'r' => '\r', //换行
't' => '\t',
'u' => ProcessUnicodeEscape(span, ref spanIndex), //处理unicode字符,\u8424\u706b\u521d\u8292 -> 萤火初芒
4.2 数字处理
数字的处理比较简单,可以用库去实现,单这里列出了逐字符解析数字的过程。考虑了负数、小数点、科学计数等。
为了更好的展示自定义的功能,我们加入了对特殊数字表达的解析,如{"name:":.9527}
。这样有一个好处,就是存储记录的时候省去了开头的一个0
。一般的通用标准库是不支持对纯小数点开头的值.9527
解析的。具体代码如下:
csharp
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解析类,基于这个类,我们开发了一个简单读取修改保存的配置文件的库,简单的使用示例如下,可配置应用与任何场景,无需提前定义实体类映射:
csharp
// 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文件如下:
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
可以关注微信公众号,更多想法更多内容欢迎交流!