[博客翻译]用500行Rust解析JSON


原文地址:https://www.krish.gg/blog/json-parser-in-rust


用500行Rust代码解析JSON

背景

上学期在大学,我参加了一门叫“基于语法的工具与编译器”的课程。这门课主要学习为一种名为PL0的语言构建扫描器、解析器和编译器等内容。我们在课上使用了Python,但那时我对学习Rust非常感兴趣。

因此,我决定启动一个课外项目(没错,又一个!)。这次,我想尝试用Rust构建一个JSON解析器。我的目标是检验课程中学到的技能,并终于着手完成我一直拖延了三年的Rust项目。


计划

我发现学习编程的最佳方法就是直接动手实践。所以我打算按照这个思路来行动。我找到了JSON规范并开始阅读。这个规范中有许多清晰的图表,能够帮助我们理解JSON文档的结构。

构建“解析器”有多种方式,我可以进行验证、扫描、标记化,然后再最终解析JSON。但我希望保持简单,所以跳过了这些步骤,专注于从原始文本文件/字符串中解析JSON,并将其表示为一个Rust枚举类型,以反映JSON的结构。

还有一种工具可以根据语法规则自动生成自顶向下或自底向上的解析器,但我的实现是一种手写解析器。这种方法更加灵活,不严格受限于规则或实现细节,使我能够轻松修改代码。


实现

如何在Rust中表示JSON?

为了存储解析后的JSON,我需要一种方法来在Rust中表示数据。我首先创建了一个通用的枚举JSONValue,用于表示JSON文档的“树”结构。每个“节点”可以是多种类型之一:字符串、数字、对象、数组、布尔值或空值(null)。根节点是一个JSON对象。

最终,我设计了如下枚举:

#[derive(Debug, Clone, PartialEq)]
enum JSONValue {
  Null,
  True,
  False,
  Number(f64),
  String(String),
  Array(Vec<JSONValue>),
  Object(HashMap<String, JSONValue>),
}

错误处理怎么办?

需要注意的是,解析是一个可能会失败的过程——源文本可能存在语法错误,解析器应能够妥善处理这些问题。因此,我决定让解析器返回一个Result类型。如果解析成功,则返回解析后的JSON值;否则返回错误。

以下是用于表示解析过程中可能发生的不同错误类型的枚举:

enum JSONParseError {
  Error(usize),
  NotFound,
  UnexpectedChar(usize),
  MissingClosing(usize),
}

注意,某些错误带有usize值,表示发生错误时输入字符串剩余的长度。这有助于确定输入字符串被消耗了多少,从而打印更好的错误消息。NotFound错误更多是内部错误,用于指示解析器未能在输入字符串中找到预期元素。


JSON中的“值”

根据JSON规范,一切皆始于一个元素——它是由空白符包围的值。该值可以是以下类型之一:

  • 对象
  • 数组
  • 字符串
  • 数字
  • "true"
  • "false"
  • "null"

简单值

我希望从最简单的值开始,然后逐步扩展到更复杂的类型。所以,我先从null值开始。以下是处理null值的一个简单函数:

fn null(src: &str) -> Result<(&str, JSONValue), JSONParseError> {
  match src.strip_prefix("null") {
    Some(rest) => Ok((rest, JSONValue::Null)),
    None => Err(JSONParseError::NotFound),
  }
}

这段代码只是检查输入字符串是否以"null"开头。如果是,则返回剩余字符串和JSONValue::Null;否则返回错误,表明未找到预期值。

类似地,我也对truefalse值采取了相同的方法,只需将"null"替换为"true"或"false"。

字符串

乍一看,解析JSON中的字符串似乎很简单——只需要找到开始和结束引号之间的内容即可。但实际上并非如此。字符串可能包含转义序列,例如\"\\\n等,因此需要仔细处理以正确解析字符串。

下面是部分字符串解析代码:

fn string(mut src: &str) -> Result<(&str, JSONValue), JSONParseError> {
  // 确保以引号开头
  match src.strip_prefix("\"") {
    Some(rest) => src = rest,
    None => return Err(JSONParseError::NotFound),
  };
  let mut result: String = "".to_string();
  let mut escaping = false; // 标志位
  let mut chars = src.chars(); // 迭代器
  loop {
    let c = match chars.next() {
      Some(c) => c,
      None => return Err(JSONParseError::MissingClosing(src.len())),
    };
    if c == '\\' && !escaping {
      escaping = true;
    } else if c == '"' && !escaping {
      break;
    } else if escaping {
      match c {
        '"' => result.push('"'),
        ... // 其他转义序列
        _ => {
          return Err(JSONParseError::UnexpectedChar(chars.count()));
        }
      }
      escaping = false;
    } else {
      result.push(c);
    }
  }
  Ok((chars.as_str(), JSONValue::String(result)))
}

这段代码首先查找起始引号,然后逐字符读取,直到遇到结束引号为止。若遇到反斜杠(\),说明接下来的字符是转义字符,需要特殊处理。

数字

在普通编程语言中,我们通常使用多种数据类型来表示数字,如不同大小的整数、浮点数等。而在JSON中,只有一种数字类型——任意值,可以是整数、浮点数或科学计数法表示的数字。

在我的解析器中,每个数字都被表示为f64(浮点数)。这是在Rust中表示数字的一种简单方法,但它不支持JSON允许的完全任意精度。这是我的解析器的一个限制,但目前我愿意接受。

以下是数字解析的核心逻辑:

fn number(mut src: &str) -> Result<(&str, JSONValue), JSONParseError> {
  let mut result;
  let negative;
  match integer(src) {
    Ok((rest, num)) => {
      result = num.abs() as f64;
      negative = num.is_negative();
      src = rest;
    }
    Err(e) => return Err(e),
  };
  match fraction(src) {
    Ok((rest, frac)) => {
      result += frac;
      src = rest;
    }
    Err(JSONParseError::NotFound) => {}
    Err(e) => return Err(e),
  }
  match exponent(src) {
    Ok((rest, exponent)) => {
      src = rest;
      let multipier = 10_f64.powf(exponent as f64);
      result *= multipier;
    }
    Err(JSONParseError::NotFound) => {}
    Err(e) => return Err(e),
  }
  if negative {
    result *= -1.0;
  }
  Ok((src, JSONValue::Number(result)))
}

此代码分为三部分:整数部分、小数部分和指数部分。解析器逐一读取这些部分,并构造出一个f64值。

列表与对象

数组和对象都是值的集合。数组是有序值列表,而对象则是无序的键值对集合。解析器需要同时处理这两种类型。

从语法上看,这两者都是由逗号分隔的元素组成的集合。对于每种类型,解析器需要能够处理三种情况:

  • 没有元素
  • 一个元素
  • 多个元素

没有元素的情况很简单——只需找到一对括号,中间可能是空白符。对于后两种情况,可以通过循环不断读取元素,直到遇到逗号为止。这样可以简单地解析这些集合,但仍需注意不能跳过无效的JSON值,因此适当的错误处理至关重要。

以下是相关代码:

fn elements(mut src: &str) -> Result<(&str, Vec<JSONValue>), JSONParseError> {
  let mut values = vec![];
  loop {
    match element(src) {
      Ok((rest, v)) => {
        src = rest;
        values.push(v);
      }
      Err(e) => return Err(e),
    }
    if src.chars().next() == Some(',') {
      src = &src[1..];
    } else {
      break;
    }
  }
  Ok((src, values))
}

组装解析器

完成所有组件后,我们现在来到了解析器的核心。当我们看到JSON在API中的使用时,它通常用于传递对象。然而,根JSON对象实际上可以是我们讨论过的任何类型:字符串、数字、对象、数组、布尔值或空值。

因此,解析器首先检查根JSON值属于哪种类型,然后调用相应的函数进行解析。这按照JSON规范的顺序进行。

fn value(src: &str) -> Result<(&str, JSONValue), JSONParseError> {
  match object(src) {
    Ok(res) => return Ok(res),
    Err(JSONParseError::NotFound) => {} // 如果未找到,则继续
    Err(e) => return Err(e),
  }
  // 类似地,依次匹配其他类型
  ...
  match null(src) {
    Ok(res) => return Ok(res),
    Err(JSONParseError::NotFound) => {} // 如果未找到,则继续
    Err(e) => return Err(e), // 如果其他错误,向上抛出
  };
  Err(JSONParseError::NotFound)
}

这只是一个简单的流程——依次尝试将根JSON值解析为每种类型。如果有任何一种类型解析成功,则返回结果。如果都不成功,则返回错误。

至此,解析器完成。它可以在仅仅500行代码中将JSON字符串解析为Rust的JSONValue枚举类型。


测试与性能

我编写了一些单元测试,以确保解析器按预期工作。GitHub上有一组JSON解析器的基准测试文件,我使用了canada.jsontwitter.json文件来测试解析器。解析器能够正确解析这些文件,因此我对结果感到满意。

对于性能测试,我在yyjson的GitHub页面上发现了一张关于不同JSON解析器速度的图表。在canada.json文件上,所有解析器的速度都在1 GB/s以下。我的解析器并未针对性能优化,因此我不期望它有多快。即便如此,我还是运行了一个简单的基准测试,看看它与其他解析器相比如何。

测试代码如下:

let big_file = std::fs::read_to_string("canada.json").expect("无法读取文件");
let num_bytes = big_file.len();
let mul = 1000;
let bytes_to_parse = num_bytes * mul;
let start_time = std::time::Instant::now();
for _ in 0..mul {
  let _ = parse(big_file.as_str());
}
let end_time = std::time::Instant::now();
let bps = bytes_to_parse as f64 / (end_time - start_time).as_secs_f64();
let mbs = (bytes_to_parse as f64) / (1_000_000.0);
let mbps = mbs / (end_time - start_time).as_secs_f64();
println!("解析速度: {:.2} Bytes/s", bps);
println!("解析速度: {:.2} MB/s", mbps);

测试结果显示,我的解析器解析canada.json文件的速度约为:

解析速度: 52014622.29 Bytes/s
解析速度: 52.01 MB/s

虽然这不是一个很高的速度,但在无需性能优化的情况下,解析大JSON文件仍能在一秒钟内完成。我很满意这个结果,也许有一天我会回来尝试进一步优化它。


易读的错误信息

最后,我希望让错误信息更易读。当前的错误信息只是带有编号的枚举值。我希望它们能像Python错误一样,易于人类理解。我希望能够知道输入字符串中的哪个具体位置导致了错误,并打印周围的上下文以便调试问题。

通过一些调整,我使用与错误相关的usize值计算出了错误所在的行号和列号,并据此打印带有上下文的错误信息。我还添加了一个漂亮的箭头指向错误的确切位置。

示例如下:

Error: UnexpectedChar(76)
----------------------------------------------------------------
 "age": 30,
 "cars": ["Ford \e This has an invalid escape", "BMW", "Fiat"],
          ^
          |
          |
Error: Unexpected Character on Line 4 Char 19

这种方式大大简化了调试解析器时的问题。


困惑

我对解析器的大部分内容都理解得很好,但有一个现象让我非常困惑。当我运行twitter.json的解析器时,通过cargo run --release命令,解析器的速度大约为60 MB/s。

但当我通过sudo cargo run --release命令运行解析器时,速度提高到了100+ MB/s。我不知道为什么会这样。使用sudo显著提高了我的解析器速度。如果你有任何想法,请告诉我。


结束语

这是一个很有趣的项目。我学到了很多关于Rust、解析器和JSON的知识。我还学会了如何从零开始编写解析器,这是一种很棒的体验。我很满意结果,也很高兴终于静下心来学习Rust。

阅读全文(20积分)