正则表达式深度剖析与最佳实践
1. 正则表达式深入探究
1.1 环视匹配的特性
在正则表达式匹配中,整体匹配值(如 ‘cat’、‘mat’、‘floor’)不包含冠词,这是因为匹配冠词的标记位于零宽度的后视断言中。不过,捕获组 ‘noun’ 仍能在引擎处理后视断言时捕获这些内容。这种不消耗文本就能捕获的能力,结合环视匹配的大小可变性,为创意正则表达式提供了很大的发挥空间。
例如,
(?!$)
前瞻断言仅包含零宽度标记,所以
(?<!$)
在功能上与之等效。若将其他环视匹配改为相反方向,或者移除原子组,会出现什么情况呢?可以在 PowerShell 中尝试这些更改,这能让你深入了解正则表达式引擎处理环视匹配的方式。
1.2 条件逻辑
条件匹配结构为正则表达式引入了 if-then-else 逻辑。if 部分可以是子表达式,引擎将其视为零宽度断言,类似于环视匹配;也可以是数字索引或组名,此时引擎会检查该组是否有捕获内容。然后,你可以通过为 then 和 else 部分使用不同的子表达式来定制模式的响应。
表达式条件匹配的标准语法是
(?(subexpression)yes|no)
。以下是一个使用子表达式进行条件逻辑匹配的示例:
$WithId = '<div id="some-id" class="styleA styleB">'
$WithoutId = '<div class="styleA styleB">'
$MyRegex = [regex]::new('(?mi)^<div(?([^>]+id="[^"]+")(?:\s*(?<name>[^"]+)=' +
'"(?<value>[^"]+)")*\s*|(?<attribs>[^>]+))>$')
$ProccessMatches = {
param($Match)
Write-Host '
Attributes:'
$Names = $Match.Groups['name'].Captures
$Values = $Match.Groups['value'].Captures
for ($i = 0; $i -lt $Names.Count; $i++) {
Write-Host (
'
{0} = {1}' -f $Names[$i].Value,
($Values[$i].Value -split ' ' -join ', ')
)
}
Write-Host (
'
Raw attributes: [' + $Match.Groups['attribs'].Value + ']'
)
}
Write-Host 'With Id:'
& $ProccessMatches -Match $MyRegex.Match($WithId)
Write-Host 'Without Id:'
& $ProccessMatches -Match $MyRegex.Match($WithoutId)
上述代码的匹配结果如下:
With Id:
Attributes:
id = some-id
class = styleA, styleB
Raw attributes: []
Without Id:
Attributes:
Raw attributes: [ class="styleA styleB"]
该模式根据 HTML 标签中是否存在
id
属性,以不同方式捕获文本。如果存在
id
属性,模式会提取每个属性的名称和值;否则,会将属性范围作为一个整体收集。
捕获组条件匹配的标准语法是
(?(NNN)yes|no)
(其中
NNN
是组索引)或
(?(name)yes|no)
(其中
name
是组名)。以下是一个使用捕获组条件逻辑匹配 GUID 的示例:
$MyPattern = '^(\{)?[a-f0-9]{8}-[a-f0-9]{4}-[0-4][a-f0-9]{3}-' +
'[ab89][a-f0-9]{3}-[a-f0-9]{12}(?(1)\})$'
'96d30676-14d2-411c-b3f7-78f1708221e2' -imatch $MyPattern
'{96d30676-14d2-411c-b3f7-78f1708221e2' -imatch $MyPattern
'96d30676-14d2-411c-b3f7-78f1708221e2}' -imatch $MyPattern
'{96d30676-14d2-411c-b3f7-78f1708221e2}' -imatch $MyPattern
匹配结果如下:
True
False
False
True
此模式可匹配带或不带花括号
{}
的 GUID。如果找到左花括号,则必须有右花括号,否则匹配失败。
1.3 平衡组
在 .NET 正则表达式中,由于缺乏递归匹配功能,要匹配嵌套结构,可以使用平衡组。平衡组不是构建递归栈,而是从现有捕获组的捕获栈中弹出捕获内容。如果输入字符串包含平衡的嵌套结构,现有组应该没有剩余的捕获内容,你可以使用条件语句进行检查。
平衡组的标准语法是
(?<Closing-Opening>...)
,其中存在一个现有的捕获组
(?<Opening>...)
。如果平衡组
Closing-Opening
的内容匹配,引擎会弹出(移除)
Opening
组的最后一个捕获内容,然后将该捕获内容与平衡组之间的所有文本存储在
Closing
组的新捕获中。
以下是一个使用平衡组捕获中间文本的示例:
$MyPattern = '(?<Open>\()[^\(\)]+(?<Close-Open>\))'
$MyString = 'Some of this sentence is (enclosed in parentheses).'
$Result = [regex]::Match($MyString, $MyPattern)
if ($Result.Success) {
$Result.Groups.ForEach{
Write-Host ('Group {0}:' -f $_.Name)
$_.Captures.ForEach{
Write-Host ('
Capture: "{0}"' -f $_.Value)
}
}
}
输出结果如下:
Group 0:
Capture: "(enclosed in parentheses)"
Group Open:
Group Close:
Capture: "enclosed in parentheses"
可以看到,
Open
组的捕获内容中没有左括号
(
,引擎在
Open
组匹配时将该捕获内容压入栈,在
Close-Open
平衡组匹配时将其弹出。同时,右括号
)
也不在任何捕获内容中,引擎不捕获平衡组结构的内容,但会消耗它们。
还可以省略关闭组名称
(?<-Opening>...)
以防止捕获中间文本,也可以使用空的平衡组结构
(?<Closing-Opening>)
来确保移除最新的
Opening
捕获内容。
以下是一个匹配嵌套引号短语的示例:
$MyPattern = '^[^"]*(?:(?:(?<StartQuote>(?=[\p{P} ]|^)")[^"]*)+' +
'(?:(?<Quotes-StartQuote>"(?=[\p{P} ]|$))[^"]*)+)*(?(StartQuote)(?!))$'
$BalancedQuotes = '"Hello?", I queried. The stranger replied, ' +
'"Why is "Hello" a question?".'
$UnbalancedQuotes = 'Hello?", I queried. The stranger replied, ' +
'"Why is "Hello" a question?".'
$ResultBalanced = [regex]::Match($BalancedQuotes, $MyPattern)
$ResultUnbalanced = [regex]::Match($UnbalancedQuotes, $MyPattern)
Write-Host 'Balanced match:' $ResultBalanced.Success
Write-Host 'Unbalanced match:' $ResultUnbalanced.Success
Write-Host (
'Balanced StartQuote capture count: ' +
$ResultBalanced.Groups['StartQuote'].Captures.Count
)
Write-Host 'Quoted phrases from balanced string:'
$ResultBalanced.Groups['Quotes'].Captures | Format-Table
输出结果如下:
Balanced match: True
Unbalanced match: False
Balanced StartQuote capture count: 0
Quoted phrases from balanced string:
Index Length Value
ValueSpan
----- ------ -----
---------
1 6 Hello?
52 5 Hello
44 26 Why is "Hello" a question?
平衡组
Quotes
从平衡字符串中捕获嵌套的引号短语。空的
StartQuote
组是指示器,模式使用条件语句
(?(StartQuote)(?!))
进行断言。
(?!)
部分是一个空的负向前瞻断言,总是失败,如果
StartQuote
仍有捕获内容,它将作为断点,导致匹配失败。
2. 正则表达式最佳实践
2.1 受限和不受限输入
在处理模式匹配问题时,要始终考虑输入的来源。受限输入是来自可靠源且遵循预期格式的数据,如已知应用程序的输出或已验证内容有效性的数据库数据;不受限输入则可能与预期不同,包括用户输入以及用户可能影响其格式的数据,如直接记录用户输入的日志文件。
如果不能确定输入文本是受限的,应将模式构建为处理不受限输入的形式。这是防御性编码的一个方面,从一开始就以防御性方式编写正则表达式模式,比后期调整现有模式以处理意外输入要容易得多。
2.2 回溯和指数运算
每次使用允许无限匹配次数的量词(如
+
、
?
、
*
及其惰性版本)时,会为正则表达式引擎创建分支机会。引擎必须保存这些量词消耗输入文本时每个可能分支的起始点,以便在需要时回溯到这些状态。
在很多情况下,回溯并无益处,甚至可能降低非匹配输入的性能,例如灾难性回溯。保存每个分支状态会带来性能损失,在处理大输入时更为明显。因此,在不影响模式匹配输入文本能力的情况下,应尽量避免回溯,可以使用原子组或环视匹配来实现。
2.3 使用正则表达式超时防止 ReDoS
考虑到不受限输入和回溯的问题,可能会出现利用这些弱点的恶意输入。.NET 正则表达式构造函数和静态方法支持
matchTimeout
参数,如果操作时间超过超时时间,会中止匹配过程并抛出异常。这在生产环境中非常重要,因为不受限输入与易受攻击的模式结合可能导致正则表达式拒绝服务(ReDoS)。
自 .NET 4.5 及以上版本支持正则表达式超时功能。可以在相关示例中看到正则表达式超时如何防止灾难性回溯不受控制地继续。原生 PowerShell 正则表达式运算符不支持匹配超时,因此在无监督命令或生产环境中,应使用 .NET 方法的此功能。
2.4 适度捕获
捕获是正则表达式中有用但代价较高的功能。每个捕获组都需要引擎构建一个捕获栈,对于大输入,这可能在计算和内存方面都很昂贵。要记住,正则表达式匹配至少会创建一个捕获——整个匹配结果。只有在需要从匹配范围中提取额外子字符串时,才使用捕获组。
以下是一个展示过度捕获性能损失的示例:
$LongString = ('This is a single sentence. ' * 1e5).Trim()
Write-Host ('String length: {0:n0} chars' -f $LongString.Length)
# Matches multiple sentences
# WARNING: This pattern backtracks catastrophically with invalid input
$OneCapture = [regex]::Matches($LongString,
'(?m)\b(?:([\w"''\(\)/-]+)[;,]?\s*)+[.?!]+')
$TwoCaptures = [regex]::Matches($LongString,
'(?m)\b(([\w"''\(\)/-]+)[;,]?\s*)+[.?!]+')
Write-Host (
'One capture: {0} ms' -f
(Measure-Command { $OneCapture.Count }).TotalMilliseconds
)
Write-Host (
'Two captures: {0} ms' -f
(Measure-Command { $TwoCaptures.Count }).TotalMilliseconds
)
输出结果如下:
String length: 2,699,999 chars
One capture: 120.4441 ms
Two captures: 244.4458 ms
此示例中的两个模式都在每次匹配中捕获一个句子,并将句子中的每个单词作为额外捕获。但第二个模式产生了过多捕获,它同时捕获了单个单词和后面带有空格的单词,导致每个匹配中包含两份几乎相同的文本。
因此,在不需要提取文本时,应使用非捕获组
(?:...)
,例如用于重复的分组结构。还可以使用
ExplicitCaptures
正则表达式选项或内联选项
(?n)
来禁用所有未命名组的捕获。
2.5 静态方法与实例方法及缓存
.NET Regex 类有静态方法和实例方法。默认情况下,正则表达式引擎会缓存使用静态方法的最后十五个模式。如果在静态调用中使用
Compiled
选项,会缓存编译后的 CIL 字节码。可以使用
[Regex]::CacheSize
静态属性设置或获取引擎存储的模式数量,这能提高包含静态正则表达式方法调用的循环语句的效率。
当实例化
Regex
对象时,引擎会绕过此缓存,创建新的解释或编译后的正则表达式模式。在代码执行过程中,要考虑这一点以避免性能下降。可以在任何地方使用静态方法,无论是否使用
Compiled
标志,这样能利用静态缓存,无需考虑实例化成本,但如果在一个会话中使用大量唯一模式,可能效率较低。
使用类实例时,引擎无需重新解释或编译已处理的模式,只要对象在作用域内,模式就可用。应在最外层的适当作用域中进行单例实例化,例如:
- PowerShell 模块:在模块作用域内,任何函数之外。
- PowerShell 类:作为静态属性。
- 脚本函数:在
begin {}
块中。
当然,如果模式会发生变化,可能需要在每个方法或函数调用中创建新对象,此时应遵循相同规则,尽量减少同一模式的实例数量。
2.6 不再使用 CompileToAssembly()
在旧版仅支持 Windows 的 PowerShell 和 .NET 版本中,
Regex
类提供了一个静态方法,用于编译多个正则表达式并将其导出到程序集。现在该方法会抛出异常,应使用
Compiled
正则表达式选项代替。
综上所述,掌握正则表达式的高级特性和最佳实践,能帮助你编写更高效、更健壮的正则表达式模式,应对各种复杂的文本匹配场景。
2.7 总结与建议
为了更好地应用正则表达式,下面总结一些关键要点和实用建议:
2.7.1 要点总结
- 输入处理 :区分受限和不受限输入,采用防御性编码构建正则表达式模式。
- 性能优化 :避免不必要的回溯,使用原子组或环视匹配;适度使用捕获组,优先考虑非捕获组;合理选择静态方法或实例方法,利用缓存提高效率。
- 安全保障 :使用正则表达式超时功能防止 ReDoS 攻击。
2.7.2 实用建议
| 场景 | 建议 |
|---|---|
| 日常开发 |
优先使用静态方法,利用缓存提升性能;对于重复使用的模式,考虑使用
Compiled
选项。
|
| 处理大输入 | 避免使用可能导致灾难性回溯的模式,使用原子组或环视匹配减少回溯;控制捕获组的使用,降低内存消耗。 |
| 生产环境 | 启用正则表达式超时功能,防止恶意输入导致的 ReDoS 攻击;对输入进行严格验证,确保输入为受限输入。 |
2.8 流程图示例
下面是一个简单的流程图,展示了在选择正则表达式方法时的决策过程:
graph TD;
A[开始] --> B{是否多次使用同一模式};
B -- 是 --> C{是否在生产环境};
C -- 是 --> D[使用实例方法并启用 Compiled 选项];
C -- 否 --> E[使用静态方法并考虑 Compiled 选项];
B -- 否 --> F[使用静态方法];
D --> G[执行正则匹配];
E --> G;
F --> G;
G --> H[结束];
2.9 代码示例回顾
为了帮助大家更好地理解上述内容,下面回顾一下前面提到的部分代码示例:
2.9.1 过度捕获性能损失示例
$LongString = ('This is a single sentence. ' * 1e5).Trim()
Write-Host ('String length: {0:n0} chars' -f $LongString.Length)
# Matches multiple sentences
# WARNING: This pattern backtracks catastrophically with invalid input
$OneCapture = [regex]::Matches($LongString,
'(?m)\b(?:([\w"''\(\)/-]+)[;,]?\s*)+[.?!]+')
$TwoCaptures = [regex]::Matches($LongString,
'(?m)\b(([\w"''\(\)/-]+)[;,]?\s*)+[.?!]+')
Write-Host (
'One capture: {0} ms' -f
(Measure-Command { $OneCapture.Count }).TotalMilliseconds
)
Write-Host (
'Two captures: {0} ms' -f
(Measure-Command { $TwoCaptures.Count }).TotalMilliseconds
)
2.9.2 匹配 GUID 示例
$MyPattern = '^(\{)?[a-f0-9]{8}-[a-f0-9]{4}-[0-4][a-f0-9]{3}-' +
'[ab89][a-f0-9]{3}-[a-f0-9]{12}(?(1)\})$'
'96d30676-14d2-411c-b3f7-78f1708221e2' -imatch $MyPattern
'{96d30676-14d2-411c-b3f7-78f1708221e2' -imatch $MyPattern
'96d30676-14d2-411c-b3f7-78f1708221e2}' -imatch $MyPattern
'{96d30676-14d2-411c-b3f7-78f1708221e2}' -imatch $MyPattern
通过对这些代码的分析和实践,你可以更深入地理解正则表达式的性能优化和高级应用。
2.10 实战演练
为了让大家更好地掌握正则表达式的最佳实践,下面提供一个实战演练场景:
2.10.1 场景描述
假设你需要从一个包含大量 HTML 标签的文本中提取所有
<a>
标签的
href
属性值。同时,要确保代码在处理大输入时具有良好的性能,避免出现灾难性回溯。
2.10.2 解决方案
# 示例 HTML 文本
$htmlText = '<html><body><a href="https://example.com">Example Link</a><a href="https://test.com">Test Link</a></body></html>'
# 正则表达式模式
$pattern = '(?i)<a\s+href="([^"]+)"'
# 使用静态方法进行匹配
$matches = [regex]::Matches($htmlText, $pattern)
# 输出匹配结果
foreach ($match in $matches) {
Write-Host ('Found href: {0}' -f $match.Groups[1].Value)
}
2.10.3 代码解释
-
(?i):启用不区分大小写的匹配。 -
<a\s+href=":匹配<a标签,后面跟着一个或多个空格,然后是href="。 -
([^"]+):捕获组,匹配除双引号之外的一个或多个字符,即href属性的值。 -
":匹配双引号。
通过这种方式,我们可以高效地提取 HTML 标签中的
href
属性值,同时避免了不必要的回溯。
2.11 总结
正则表达式是一个强大的工具,但要想发挥其最大威力,需要掌握其高级特性和最佳实践。通过本文的介绍,你应该对正则表达式的环视匹配、条件逻辑、平衡组等高级特性有了更深入的了解,同时也学会了如何处理不同类型的输入、优化性能、防止 ReDoS 攻击等最佳实践。希望这些知识能帮助你在实际开发中编写更高效、更健壮的正则表达式模式。
正则表达式高级技巧与实践指南
超级会员免费看
665

被折叠的 条评论
为什么被折叠?



