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

解析 ASPX 和其他混合内容

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.82/5 (5投票s)

2007年10月21日

CPOL

8分钟阅读

viewsIcon

36482

downloadIcon

317

使用正则表达式的迭代解析算法

引言

我称之为“混合内容”的文件在开发中变得越来越流行。例如,它们包括 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 次。这样,如果注释是行上的最后一个内容,我也会匹配换行符。

应用此正则表达式后,它将允许我们识别哪些部分是注释。我将把它们标记为注释

dcccbbbxxbbbxxbbbsssbb

(我已经加粗并划掉了已识别的部分。)

第二次迭代

在第二次迭代中,我们将查找指令(那些以“<%@”开头并以“%>”结尾的特殊标签,例如 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 类表示文件的一个部分。

Class diagram

当我创建一个 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 日:初始发布
© . All rights reserved.