从零开始: c#纯代码实现完整Json解析器的全过程及注释与自定义格式的支持实现

大家好!我们要深入探讨一个非常常用的技术: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

可以关注微信公众号,更多想法更多内容欢迎交流!

从零开始: c#纯代码实现完整Json解析器的全过程及注释与自定义格式的支持实现

发表评论

评论已关闭。

相关文章