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





5.00/5 (5投票s)
使用 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
表示我们希望同时生成 JsonStringRunner
和 JsonTextReaderRunner
。当指定 /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
下没有空格,或者直到没有更多输入。请注意,没有通用的词法分析器基类定义符号,但是 JsonStringRunner
和 JsonTextReaderRunner
的符号 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 值的所有情况。值可以是任何东西——无论是像 boolean
或 string
这样的标量值,还是 object
或 array
。我们已经为其中一些函数编写了代码,所以剩下的主要是数字、布尔值、字符串和 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 日 - 首次提交