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

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

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