解析 ASPX 和其他混合内容






3.82/5 (5投票s)
使用正则表达式的迭代解析算法
引言
我称之为“混合内容”的文件在开发中变得越来越流行。例如,它们包括 HTML、XML、ASPX……它们都有一个共同点:它们的内容由不同类型的内容组成:文本、标签、脚本等。您甚至可以认为逗号分隔的文件是混合内容,由值、列分隔符和行分隔符组成。
随着这类文件越来越受欢迎,解析它们的需求也随之而来。无论是创建具有语法高亮的编辑器,还是创建执行它们的引擎。
最近,我想尝试创建一个代码生成引擎,该引擎将操作 ASPX 风格的模板文件。经典的有限状态机方法似乎并不难,但有一个重要的缺点:当需要向模板语言添加新功能时,它很难维护。
我尝试编写一个组件,该组件能够解析混合内容文件,而无需为该文件或内容编写特定的有限状态机。结果是,解析算法的大部分内容将是可重用的。
有限状态机方法
首先,让我们看看经典的有限状态机方法。
假设我想解析一个 ASP(X) 风格的文件,例如
<html>
<head><title><%= document.Title %></title></head>
<body>
<h1>Welcome <%= person.Name %>.</h1>
<body>
</html>
如果我想解析此文件以便生成写入此类文件的写入语句,我可以编写一个如下所示的有限状态机
StringBuilder buffer = new StringBuilder();
string bufferString = String.Empty;
bool inSource = true;
bool inExpression = false;
foreach (char c in source)
{
buffer.Append(c);
bufferString = buffer.ToString();
if (inSource)
{
if (bufferString.EndsWith("<%="))
{
bufferString = bufferString.Replace("\r\n", "\\r\\n");
bufferString = bufferString.Replace("\"", "\\\"");
WriteLiteral(bufferString.Substring(0,
bufferString.Length - 3));
buffer = new StringBuilder();
inSource = false;
inExpression = true;
}
}
else if (inExpression)
{
if (bufferString.EndsWith("%>"))
{
bufferString = bufferString.Replace("\"", "\\\"");
WriteExpression(bufferString.Substring(0,
bufferString.Length - 2));
buffer = new StringBuilder();
inSource = true;
inExpression = false;
}
}
}
// Buffer might not be empty:
if (inSource)
{
bufferString = buffer.ToString();
bufferString = bufferString.Replace("\r\n", "\\r\\n");
bufferString = bufferString.Replace("\"", "\\\"");
WriteLiteral(bufferString.Substring(0,
bufferString.Length - 3));
}
else if (inExpression)
{
bufferString = bufferString.Replace("\"", "\\\"");
WriteExpression(bufferString.Substring(0,
bufferString.Length - 2));
}
通过正确实现 WriteLiteral
和 WriteExpression
方法,输出可以是
writer.Write("<html>\r\n<head><title>");
writer.Write(document.Title);
writer.Write("</title></head>\r\n<body>\r\n<h1>Welcome ");
writer.Write(person.Name);
writer.Write(".</h1>\r\n<body>\r\n</html>\");
这是一个相当多的代码,它将随着每个新支持的功能(脚本、指令(考虑 ASP.NET 中的“<%@Page...%>
”指令)、#include
元素、注释支持等)而稳步增加。
此外,代码很难维护。它包含对分隔符字符串、它们的长度的许多引用,并且包含相当多的重复代码。
不可否认,上述代码中的一些问题可以得到改进。例如,重复代码可以很容易地消除。但让我们看看一种完全不同的方法。
使用正则表达式匹配
正则表达式是搜索字符串中模式的极其强大的技术。
使用正则表达式,您可以例如匹配指令标签,如:“<%@
”后面可能跟着空格,然后是一个单词,然后是 0 个或多个带有值的属性,最后是“%>
”,而这正是我们需要的匹配类型。
如果您不熟悉正则表达式,如果您对解析算法感兴趣,请考虑学习它们。我喜欢 Dan Appleman 的电子书“Regular Expressions with .NET”(可在亚马逊购买),我只需要阅读其中的 10 页并查看附录就可以获得我需要的所有信息。
使用正则表达式,我们可以匹配指令、脚本、表达式等。所以我们有了解决方案:我们只编写一些正则表达式,然后就完成了!
不幸的是,情况并非如此。脚本的正则表达式匹配将匹配所有“<%
”和“%>
”对。无论这些对是否出现在注释中,它都会这样做。因此,即使我们使用正则表达式,我们也需要某种形式的状态机来检测是否应该考虑这些匹配。
将正则表达式与类似状态机的代码结合起来是微软编写 ASPX 解析器的方式。微软的解析器由 System.Web.dll 中的 System.Web.UI.TemplateParser
类实现。
基于混合内容文件包含可以嵌套的不同类型内容的块的想法,我实现了一个算法,该算法将迭代地仅匹配选定的内容类型,以使用正则表达式发现新内容类型。
迭代匹配算法
考虑之前的 ASP(X) 示例文件。假设我也以 @Page
指令开头,后面跟着一个注释块,并在文件中包含了一个脚本。然后,我可以象征性地将该文件表示为
dcccbbbxxbbbxxbbbsssbb
含义:一个指令(d),一些构成注释的字符(c),一些构成文字正文文本的字符(b),一些构成表达式的字符(x),再次是一些正文字符,一个表达式,正文,然后一些构成脚本的字符(s),最后再次是一些正文。
到目前为止,我们知道此文件中存在混合内容,但对于计算机来说,它只是一个大的字符字符串。我们现在将通过几次迭代来识别文件内容
第一次迭代
在第一次迭代中,我们查找注释。正如我们将看到的,匹配执行的顺序很重要。我们首先查找注释,以便以后可以忽略出现在注释中的任何脚本、表达式等。
为了搜索注释,我们可以使用以下正则表达式(请记住,注释是任何以“<%--
”开头并以“--%>
”结尾的内容)
<%--.*?--%>(\w*\r?\n)?
(请记住,在 C# 中复制时使用双反斜杠。)
“.*?
”表示任何内容(点),零次或多次(*
),但尽可能少(问号),这将匹配结束标签(--%>
)的第一次出现。为了让点真正表示任何内容,包括换行符,您需要为正则表达式提供 SingleLine
选项。
我还包含了 "(\w*\r?\n)?"
,它将匹配所有空格(空格或制表符)包括下一个换行符,0 次或 1 次。这样,如果注释是行上的最后一个内容,我也会匹配换行符。
应用此正则表达式后,它将允许我们识别哪些部分是注释。我将把它们标记为注释
d
cccbbbxxbbbxxbbbsssbb
(我已经加粗并划掉了已识别的部分。)
第二次迭代
在第二次迭代中,我们将查找指令(那些以“<%@
”开头并以“%>
”结尾的特殊标签,例如 Page 指令)。但是,而且这一点很重要,我们将只在未识别的部分(即“d”和“bbbxxbbbxxbbbsssbb”部分)中搜索脚本。
(<%@\s*(?<elementName>\w+)
(\s+(?<name>\w+)=(?<quote>["']?)(?<value>[^"']*)\k<quote>)
*\s*%>(\w*\r?\n)?)+
(为了便于打印,我将正则表达式写成了 3 行,但您应该将其写成单行。)
此正则表达式有点复杂,但由于有命名组“elementName
”、“name
”和“value
”,因此识别指令的名称、其属性及其值将变得轻而易举。
应用此正则表达式后,结果是第一部分将被识别为指令
dcccbbbxxbbbxxbbbsssbb
第三次迭代
在第三次迭代中,我们将查找脚本。同样,我们只会在仍未识别的部分中搜索,在本例中只有“bbbxxbbbxxbbbsssbb”部分。
我们可以使用以下正则表达式
<%(?![@=])(?<body>.*?)%>(\w*\r?\n)?
这将匹配“<%
”,后面不跟任何字符 @
或 =
,然后是脚本主体 (".*?":
任何字符 0 次或多次,但尽可能少),然后是“%>
”以及可能的空格和换行符。
同样,为了让点也匹配换行符,您需要为正则表达式提供 SingleLine
选项。
将此正则表达式应用于文档未识别部分的匹配结果是
dcccbbbxxbbbxxbbbsssbb
第四次也是最后一次迭代
在最后一次迭代中,我们将查找表达式(位于“<%=
”和“%>
”之间)。
要使用的正则表达式是
<%=(?<body>.*?)%>
结果
dcccbbbxxbbbxxbbbsssbb
混合内容解析器类
为了支持此算法,我创建了几个类。MixedContentFile
类表示整个文件。ContentPart
类表示文件的一个部分。
当我创建一个 MixedContentFile
类的实例时,我向它提供文件的全部内容。然后 MixedContentFile
构造函数将创建一个初始的 ContentPart
实例,表示文件的全部内容。如果我要求文件提供其内容 Parts,我将获得一个匹配整个内容的实例。
我可以这样表示
ContentPart("dcccbbbxxbbbxxbbbsssbb", 0)
这个初始内容部分是类型 0。
使用 MixedContentFile
的 ApplyParserRegex
方法,告诉它搜索类型为 0 的所有部分,将匹配类型设置为类型 1,并将正则表达式传递给它以匹配注释,我们将看到文件现在由三个部分组成
ContentPart("d", 0)
ContentPart("ccc", 1)
ContentPart("bbbxxbbbxxbbbsssbb", 0)
此外,对于每个内容部分,我们可以获取对应于该部分在原始文件内容中的文本,并且我可以访问应用正则表达式产生的 Match
对象,并且我可以在其中搜索命名组匹配(考虑识别指令的名称、属性及其值)。
ApplyParserRegex
方法是算法的核心。每次调用该方法时,都会使用传入的正则表达式在具有给定 typeToSearch
的部分中查找匹配项,并为找到的匹配项创建新的 ContentParts
。实现如下
public void ApplyParserRegex(int typeToSearch, int typeToSearchFor,
Regex expressionToSearchFor)
{
List<ContentPart> result = new List<ContentPart>();
foreach (ContentPart section in this.parts)
{
if (section.Type == typeToSearch)
{
int pos = 0;
foreach (Match match in
expressionToSearchFor.Matches(section.Content))
{
if (pos < match.Index)
{
result.Add(new ContentPart(
section.File,
section.Type,
section.Offset + pos,
match.Index - pos,
section.Match));
}
result.Add(new ContentPart(
section.File,
typeToSearchFor,
section.Offset + match.Index,
match.Length,
match));
pos = match.Index + match.Length;
}
if (pos < section.Length)
{
result.Add(new ContentPart(
section.File,
section.Type,
section.Offset + pos,
section.Length - pos,
section.Match));
}
}
else
{
result.Add(section);
}
}
// Perform iteration substitution:
this.parts = result;
}
借助此方法以及 MixedContentFile
和 ContentPart
类,解析 ASP 风格的文件已变得轻而易举。给定本文前面描述的正则表达式,整个解析方法如下所示
static MixedContentFile Parse(string content)
{
// Initiate document:
MixedContentFile file = new MixedContentFile(content, (int)CT.Body);
// Iteration 1: comments
file.ApplyParserRegex((int)CT.Body, (int)CT.Comment, RxComment);
// Iteration 2: directives
file.ApplyParserRegex((int)CT.Body, (int)CT.Directive, RxDir);
// Iteration 3: scripts
file.ApplyParserRegex((int)CT.Body, (int)CR.Script, RxScript);
// Iteration 4: expressions
file.ApplyParserRegex((int)CT.Body, (int)CT.Expr, RxExpr);
// Return file:
return file;
}
返回的文件对象现在可以很容易地通过类似以下的代码进行解释
foreach (ContentPart part in file.Parts)
{
if (part.Type == (int)ContentTypes.Body)
{
}
else if (part.Type == (int)ContentTypes.Comment)
{
}
else if (part.Type == (int)ContentTypes.Directive)
{
}
else if (part.Type == (int)ContentTypes.Expression)
{
}
else if (part.Type == (int)ContentTypes.Script)
{
}
}
可以下载一个示例应用程序。其中包含一些演示 ASP(X) 文件,您可以使用它们来测试演示应用程序(通过将文件作为命令行参数传递给演示应用程序)。
历史
- 2007 年 10 月 21 日:初始发布