65.9K
CodeProject 正在变化。 阅读更多。
Home

Visual FA 第 5 部分:带 JSON 的真实词法分析示例

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2024 年 4 月 14 日

MIT

6分钟阅读

viewsIcon

6708

downloadIcon

120

使用 Visual FA 实现简单的 JSON 解析器

引言

文章列表

我们已经通过 Visual FA 涵盖了很多内容,但在所有这些领域中,我们还没有探索一个具体的例子。在这里,我们将使用 Visual FA 实现一个简单的 JSON 引擎的解析组件。

您可能会想,为什么我们要解析 JSON,而 .NET 已经附带了一个完全可用的 JSON 解析器。原因是 JSON 非常简单易于解析,但又足够复杂,可以进行词法分析/标记化,这使得它成为演示 Visual FA 的几乎完美场景,而解析部分又不会占用过多空间而成为干扰。我认为(至少出于演示目的)避免冗余代码比避免重新发明轮子更有价值。

我已将一个 Json 项目与 Visual FA 解决方案一起发布。我们将在此文件中探索 JsonParser.cs 文件以及一些有助于将分词器/词法分析器运行器作为构建过程一部分的项目设置。

背景

在深入研究之前,您至少需要阅读第一部分。

使用 Visual FA 生成词法分析器有几种方法。也许最简单的方法是使用 VisualFA.SourceGenerator NuGet 包,但这仅适用于 C# 9 或更高版本。

我们将使用的选项是将 LexGen 工具作为预构建步骤。这与更多 .NET 版本以及更多 .NET 语言(尽管我们使用的是 C#)兼容,但最初需要多做一些工作来设置。

我们将生成 JsonStringRunner 以从 string 中解析 JSON 内容,并生成 JsonTextReaderRunner 以从 TextReader 中解析 JSON。

使用代码

使用词法分析器构建步骤设置项目

每当您使用 LexGen 时,您都需要可以从某个地方访问它,以便将其用作构建步骤。为了将所有内容放在一起,我通常将构建工具打包在解决方案文件夹的根目录中,但如果您发现这样做很随意或不理想,则可以修改构建步骤以从任何位置提取可执行文件。

让我们看一下 Json 项目的预构建步骤。

dotnet "$(SolutionDir)LexGen.dll" "$(ProjectDir)json.rl" /class JsonRunner /dual /namespace Json /nospans /output "$(ProjectDir)JsonRunners.cs"

除了几个参数外,它相对自明。/nospans 表示我们不会生成使用 .NET span 的代码。此选项与更多 .NET 版本兼容。/dual 表示我们希望同时生成 JsonStringRunnerJsonTextReaderRunner。当指定 /dual 时,这会根据 /class 进行更改。您可以看到我们正在使用 json.rl 作为输入文件。我们将对此进行介绍。

使用 .rl 文件定义词法分析器

json.rl 文件是一个 Rolex 词法分析器格式的词法分析器规范文件。它通过允许 blockEnd 属性成为正则表达式(如果您使用单引号而不是双引号作为值)来对此格式进行少量扩展。否则,它是相同的。这是我们的 JSON 词法分析器符号名称和定义。

Object = "{"
ObjectEnd = "}"
Array = "["
ArrayEnd = "]"
FieldSeparator = ":"
Comma = ","
Number = '-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
Boolean = 'true|false'
Null = "null"
String = '"([^\n"\\]|\\([btrnf"\\/]|(u[0-9A-Fa-f]{4})))*"'
WhiteSpace = '[ \t\r\n]+'

请注意,在某些地方我们使用了单引号,这表示正则表达式。在其他地方,我们使用了双引号,这表示字符串字面量。

这最终将为我们创建两个类:JsonStringRunner(接受 string 并返回一系列 FAMatch 实例)和 JsonTextReaderRunner(接受 TextReader 并返回一系列 FAMatch 实例)。我们生成了两者,因为 string 运行器稍微快一些。显然,如果我们只使用 StringReader 创建一个包装器,我们可以避免生成该代码,但在这里我们演示了完整的流程。

使用词法分析器解析代码

虽然可以根据无上下文文法规范生成解析器代码,但在大多数情况下我不推荐这样做。原因是生成的解析器代码在大小和解析方式方面往往都很笨重。手动编写允许在“惰性”和“贪婪”匹配之间切换,最终更灵活、紧凑且高效。通常,为了适应特定的解析算法而修改文法与手动编写代码一样困难。JSON 不是这种情况,但 JSON 被设计为易于解析。但并非所有语言都是如此。

我们将使用递归下降解析来处理传入的 FAMatch 令牌。JsonParser 类(JsonParser.cs)负责此操作。

我们将从一个核心函数开始,该函数用于跳过空格,然后探索代码。

static void _SkipWS(IEnumerator<FAMatch> cursor)
{
    while (cursor.Current.SymbolId == JsonStringRunner.WhiteSpace 
        && cursor.MoveNext()) ;
}

好的,这相当简单。我们所做的就是不断前进,直到 cursor 下没有空格,或者直到没有更多输入。请注意,没有通用的词法分析器基类定义符号,但是 JsonStringRunnerJsonTextReaderRunner 的符号 ID 相同,因此我们只需通过前者类访问它们。

接下来是数组解析例程。这实际上相当简单,因为它大部分工作都是委托给 _ParseValue()

static JsonArray _ParseArray(IEnumerator<FAMatch> cursor)
{
    var position = cursor.Current.Position;
    var line = cursor.Current.Line;
    var column = cursor.Current.Column;
    var result = new JsonArray();
    _SkipWS(cursor);
    if (cursor.Current.SymbolId != JsonStringRunner.Array) 
        throw new Exception("Expected an array");
    if (!cursor.MoveNext()) 
        throw new JsonException("Unterminated array", position, line, column);
    while (cursor.Current.SymbolId != JsonStringRunner.ArrayEnd)
    {
        result.Add(_ParseValue(cursor));
        _SkipWS(cursor);
        if (cursor.Current.SymbolId == 
            JsonStringRunner.Comma)
        {
            cursor.MoveNext();
            _SkipWS(cursor);
        } else if(cursor.Current.SymbolId==JsonStringRunner.ArrayEnd)
        {
            break;
        }
    }
    return result;
}

在这里,我们存储光标位置信息,然后解析每个值,直到找到 ],并跳过逗号。

接下来,我们处理字段的解析,它是一个字符串,后跟一个字段分隔符,再后跟一个值。

static KeyValuePair<string,object> _ParseField(IEnumerator<FAMatch> cursor)
{
    var position = cursor.Current.Position;
    var line = cursor.Current.Line;
    var column = cursor.Current.Column;
    if (cursor.Current.SymbolId != JsonStringRunner.String) 
        throw new JsonException("Expecting a field name", position, line, column);
    var name = JsonUtility.DeescapeString(
        cursor.Current.Value.Substring(1, cursor.Current.Value.Length - 2));
    _SkipWS(cursor);
    if (!cursor.MoveNext()) 
        throw new JsonException("Unterminated JSON field", position, line, column);
    if (cursor.Current.SymbolId != JsonStringRunner.FieldSeparator) 
        throw new JsonException("Expecting a field separator", position, line, column);
    _SkipWS(cursor);
    if (!cursor.MoveNext()) 
        throw new JsonException("JSON field missing value", position, line, column);
    var value = _ParseValue(cursor);
    return new KeyValuePair<string, object>(name, value);
}

现在我们来处理对象的解析,它只是 { 后面跟着零个或多个字段,字段之间用逗号分隔,最后是一个 }

static JsonObject _ParseObject(IEnumerator<FAMatch> cursor)
{
    var position = cursor.Current.Position;
    var line = cursor.Current.Line;
    var column = cursor.Current.Column;
    var result = new JsonObject();
    _SkipWS(cursor);
    if (cursor.Current.SymbolId != JsonStringRunner.Object) 
        throw new JsonException("Expecting a JSON object", position, line, column);
    if (!cursor.MoveNext()) 
        throw new JsonException("Unterminated JSON object", position, line, column);
    while (cursor.Current.SymbolId != JsonStringRunner.ObjectEnd)
    {
        _SkipWS(cursor);
        var kvp = _ParseField(cursor);
        result.Add(kvp.Key, kvp.Value);
        _SkipWS(cursor);
        if (cursor.Current.SymbolId == JsonStringRunner.Comma)
        {
            cursor.MoveNext();
        } else if(cursor.Current.SymbolId == JsonStringRunner.ObjectEnd)
        {
            break;
        }
    }
    return result;
}

_ParseValue() 是一个相对重要的函数,因为它能够处理需要 JSON 值的所有情况。值可以是任何东西——无论是像 booleanstring 这样的标量值,还是 objectarray。我们已经为其中一些函数编写了代码,所以剩下的主要是数字、布尔值、字符串和 null。

static object _ParseValue(IEnumerator<FAMatch> cursor)
{
    var position = cursor.Current.Position;
    var line = cursor.Current.Line;
    var column = cursor.Current.Column;

    object? result = null;
    _SkipWS(cursor);
    switch (cursor.Current.SymbolId)
    {
        case JsonStringRunner.Object:
            result = _ParseObject(cursor);
            break;
        case JsonStringRunner.Array:
            result = _ParseArray(cursor);
            break;
        case JsonStringRunner.Number:
            result = double.Parse(
                cursor.Current.Value, 
                CultureInfo.InvariantCulture.NumberFormat);
            break;
        case JsonStringRunner.Boolean:
            result = cursor.Current.Value[0] == 't';
            break;
        case JsonStringRunner.Null:
            break;
        case JsonStringRunner.String:
            result = JsonUtility.DeescapeString(
                cursor.Current.Value.Substring(1, 
                    cursor.Current.Value.Length - 2));
            break;
        default:
            throw new JsonException("Expecting a value", 
                position, 
                line, 
                column);
    }
    cursor.MoveNext();
    return result!;
}

请注意,我们没有进行任何验证。例如,我们只检查匹配项的第一个字符是否为“t”以指示 true。初步看来这似乎并不非常健壮,但实际上它是,因为词法分析器代码已经在标记化过程中处理了验证。这简化了您的代码,并且实际上使其更加健壮,因为减少了出错的可能性,并且您没有重复工作,从而减少了维护。

与此相比,根解析函数本身微不足道。

static object? _Parse(FARunner runner)
{
    var e = runner.GetEnumerator();
    if (e.MoveNext())
    {
        // _ParseObject() would be more compliant
        // but some services will return arrays
        // and this can handle that
        return _ParseValue(e);
    }
    throw new JsonException("No content", 0, 0, 0);
}
public static object? Parse(string json)
{
    var runner = new JsonStringRunner();
    runner.Set(json);
    return _Parse(runner);
}
public static object? Parse(TextReader json)
{
    var runner = new JsonTextReaderRunner();
    runner.Set(json);
    return _Parse(runner);
}

这里的主要内容是获取一个运行器,从中获取一个枚举器,然后移动到第一个 FAMatch 令牌。然后,我们可以将枚举器传递给我们之前的解析函数。

公共函数只是包装这些,根据输入类型生成相应的运行器。

希望我已经说明了使用 Visual FA 如何减少您的工作量、提高健壮性并使您的解析代码更易于理解。

关注点

这个库可能不完全是学术性的。Visual Studio Code 使用“类 JSON”文件,这些文件是 JSON,但带有 C 风格的注释。通过在 json.rl 中添加词法分析注释,并在解析此类文件时在 _SkipWS() 中跳过它们,可以将此解析器轻松扩展为支持注释。

历史

  • 2024 年 4 月 14 日 - 首次提交
© . All rights reserved.