适用于 PowerShell 7.0 及以上版本
在编写和维护大量 PowerShell 脚本时,你是否想过如何程序化地”理解”一段代码的结构?正则表达式只能处理文本层面的匹配,却无法识别变量的作用域、函数的调用关系或参数的传递方式。PowerShell 内置的 AST(Abstract Syntax Tree,抽象语法树)引擎正是为此而生——它将脚本源码解析为一棵结构化的对象树,每个节点都代表一个语法元素。
AST 是 PSScriptAnalyzer、PowerShell Editor Services(VS Code 的 PowerShell 扩展底层)等工具的核心技术。掌握了 AST,你就能编写自己的代码静态分析工具、自动化重构脚本,甚至构建自定义的代码质量检查规则,在 CI/CD 流水线中实现脚本质量门禁。
本文将从 AST 的基础解析入手,逐步展示代码分析实战,最后实现一个自动重构工具,帮助你把 AST 技术应用到日常开发和运维中。
AST 基础解析
PowerShell 提供了 [System.Management.Automation.Language.Parser] 类来将脚本文本解析为 AST。解析后的对象可以通过 .Find()、.FindAll() 等方法遍历,轻松提取函数定义、变量引用、命令调用等语法元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| $scriptCode = @' function Get-SystemInfo { param( [string]$ComputerName = $env:COMPUTERNAME, [int]$Timeout = 30 ) $os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName $cpu = Get-CimInstance -ClassName Win32_Processor -ComputerName $ComputerName $disk = Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName
$result = [PSCustomObject]@{ Computer = $ComputerName OS = $os.Caption CPU = $cpu.Name DiskFree = ($disk | Measure-Object -Property FreeSpace -Sum).Sum } return $result }
$computer = "SERVER01" $info = Get-SystemInfo -ComputerName $computer Write-Output $info '@
$tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $scriptCode, [ref]$tokens, [ref]$errors )
if ($errors.Count -gt 0) { Write-Host "解析错误:" -ForegroundColor Red $errors | ForEach-Object { Write-Host " 行 $($_.Extent.StartLineNumber): $($_.Message)" } } else { Write-Host "脚本解析成功,AST 类型: $($ast.GetType().Name)" -ForegroundColor Green }
$functions = $ast.FindAll( { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true )
Write-Host "`n=== 函数定义 ===" foreach ($fn in $functions) { Write-Host "函数名: $($fn.Name)" Write-Host " 参数: $($fn.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } )" Write-Host " 起始行: $($fn.Extent.StartLineNumber)" Write-Host " 结束行: $($fn.Extent.EndLineNumber)" }
$variables = $ast.FindAll( { param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true )
Write-Host "`n=== 变量列表(去重)===" $uniqueVars = $variables | ForEach-Object { $_.VariablePath.UserPath } | Sort-Object -Unique foreach ($v in $uniqueVars) { $count = ($variables | Where-Object { $_.VariablePath.UserPath -eq $v }).Count Write-Host " `$$v (引用 $count 次)" }
$commands = $ast.FindAll( { param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true )
Write-Host "`n=== 命令调用 ===" $uniqueCmds = $commands | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique foreach ($cmd in $uniqueCmds) { Write-Host " $cmd" }
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 脚本解析成功,AST 类型: ScriptBlockAst
=== 函数定义 === 函数名: Get-SystemInfo 参数: ComputerName Timeout 起始行: 1 结束行: 17
=== 变量列表(去重)=== $ComputerName (引用 4 次) $Timeout (引用 1 次) $computer (引用 1 次) $cpu (引用 2 次) $disk (引用 2 次) $env:COMPUTERNAME (引用 1 次) $info (引用 1 次) $os (引用 2 次) $result (引用 2 次)
=== 命令调用 === Get-CimInstance Get-SystemInfo Measure-Object Write-Output
|
代码分析实战
掌握了 AST 基础操作后,我们来实现几个实用的代码分析功能:提取脚本的依赖关系、检测未使用的变量、以及分析函数复杂度。这些功能可以帮助你在代码审查时快速发现问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
| function Invoke-CodeAnalysis { param( [Parameter(Mandatory)] [string]$ScriptPath )
$source = Get-Content -Path $ScriptPath -Raw -Encoding Utf8 $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $source, [ref]$tokens, [ref]$errors )
$analysis = [ordered]@{}
$usingStatements = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.UsingStatementAst] }, $true ) $importCommands = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in 'Import-Module', 'Require-Module' }, $true )
$dependencies = @() foreach ($u in $usingStatements) { $dependencies += "using: $($u.Name.Value ?? $u.ModuleName)" } foreach ($ic in $importCommands) { $firstArg = $ic.CommandElements[1] if ($firstArg) { $dependencies += "module: $($firstArg.Value ?? $firstArg.Extent.Text)" } } $analysis['Dependencies'] = $dependencies
$allVarRefs = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] }, $true )
$assignments = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true ) $assignedVars = @{} foreach ($a in $assignments) { if ($a.Left -is [System.Management.Automation.Language.VariableExpressionAst]) { $varName = $a.Left.VariablePath.UserPath $assignedVars[$varName] = $a.Left.Extent.StartLineNumber } }
$unusedVars = @() foreach ($varName in $assignedVars.Keys) { $refCount = ($allVarRefs | Where-Object { $_.VariablePath.UserPath -eq $varName }).Count if ($refCount -le 1 -and $varName -notin '_', 'null', 'true', 'false') { $unusedVars += [PSCustomObject]@{ Variable = "`$$varName" Line = $assignedVars[$varName] } } } $analysis['UnusedVariables'] = $unusedVars
$functions = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true )
$funcMetrics = foreach ($fn in $functions) { $loops = $fn.Body.FindAll( { param($n) $n -is [System.Management.Automation.Language.LoopStatementAst] }, $true ) $conditions = $fn.Body.FindAll( { param($n) $n -is [System.Management.Automation.Language.IfStatementAst] }, $true ) $pipelines = $fn.Body.FindAll( { param($n) $n -is [System.Management.Automation.Language.PipelineAst] }, $true ) $cmdCount = ($fn.Body.FindAll( { param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true )).Count
$lineCount = $fn.Extent.EndLineNumber - $fn.Extent.StartLineNumber + 1
$complexity = $loops.Count * 2 + $conditions.Count + [math]::Floor($cmdCount / 5) $level = switch ($complexity) { { $_ -le 3 } { "低" } { $_ -le 8 } { "中" } { $_ -le 15 } { "高" } default { "极高" } }
[PSCustomObject]@{ Function = $fn.Name Lines = $lineCount Loops = $loops.Count Conditions = $conditions.Count Commands = $cmdCount Complexity = "$complexity ($level)" } } $analysis['FunctionMetrics'] = $funcMetrics
return [PSCustomObject]$analysis }
$testScript = Join-Path $env:TEMP "sample-analysis.ps1" @' function Get-DiskReport { $disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" $report = foreach ($disk in $disks) { $freePercent = [math]::Round($disk.FreeSpace / $disk.Size * 100, 1) if ($freePercent -lt 10) { $status = "Critical" } elseif ($freePercent -lt 25) { $status = "Warning" } else { $status = "OK" } [PSCustomObject]@{ Drive = $disk.DeviceID FreePct = $freePercent Status = $status } } return $report }
$unusedVar = "这个变量只赋值不使用" $result = Get-DiskReport $result | Format-Table '@ | Set-Content -Path $testScript -Encoding Utf8
$report = Invoke-CodeAnalysis -ScriptPath $testScript
Write-Host "=== 依赖关系 ===" -ForegroundColor Cyan $report.Dependencies | ForEach-Object { Write-Host " $_" } if ($report.Dependencies.Count -eq 0) { Write-Host " (无外部依赖)" }
Write-Host "`n=== 未使用的变量 ===" -ForegroundColor Cyan $report.UnusedVariables | Format-Table -AutoSize
Write-Host "`n=== 函数复杂度 ===" -ForegroundColor Cyan $report.FunctionMetrics | Format-Table -AutoSize
Remove-Item -Path $testScript -Force
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| === 依赖关系 === (无外部依赖)
=== 未使用的变量 === Variable Line -------- ---- $unusedVar 20
=== 函数复杂度 ===
Function Lines Loops Conditions Commands Complexity -------- ----- ----- ---------- -------- ---------- Get-DiskReport 17 1 2 2 4 (中)
|
自动重构工具
AST 不仅能用来分析代码,还可以用来生成和修改代码。下面实现一个实用的重构工具:批量重命名变量、提取代码片段为独立函数,以及从函数定义自动生成帮助文档。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
| function Invoke-CodeRefactor { param( [Parameter(Mandatory)] [string]$ScriptPath,
[Parameter(Mandatory)] [ValidateSet('RenameVariable', 'GenerateHelp', 'ExtractMetrics')] [string]$Operation,
[string]$OldName, [string]$NewName )
$source = Get-Content -Path $ScriptPath -Raw -Encoding Utf8 $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $source, [ref]$tokens, [ref]$errors )
switch ($Operation) { 'RenameVariable' { if (-not $OldName -or -not $NewName) { throw "RenameVariable 操作需要指定 OldName 和 NewName" } $varTokens = $tokens | Where-Object { $_.Kind -eq 'Variable' -and $_.Text -match "^\$$([regex]::Escape($OldName))$" }
if ($varTokens.Count -eq 0) { Write-Host "未找到变量 `$$OldName" -ForegroundColor Yellow return }
$sb = [System.Text.StringBuilder]::new($source) $replaced = 0 foreach ($vt in ($varTokens | Sort-Object { $_.Extent.StartOffset } -Descending)) { $sb.Remove($vt.Extent.StartOffset, $vt.Extent.EndOffset - $vt.Extent.StartOffset) | Out-Null $sb.Insert($vt.Extent.StartOffset, "`$$NewName") | Out-Null $replaced++ }
$newSource = $sb.ToString() $backupPath = "$ScriptPath.bak" Copy-Item -Path $ScriptPath -Destination $backupPath -Force $newSource | Set-Content -Path $ScriptPath -Encoding Utf8 -NoNewline
Write-Host "已将 `$$OldName 重命名为 `$$NewName (共 $replaced 处)" -ForegroundColor Green Write-Host "备份文件: $backupPath" }
'GenerateHelp' { $functions = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true )
foreach ($fn in $functions) { $helpBuilder = [System.Text.StringBuilder]::new() $null = $helpBuilder.AppendLine("<#") $null = $helpBuilder.AppendLine(".SYNOPSIS") $null = $helpBuilder.AppendLine(" $($fn.Name) 函数") $null = $helpBuilder.AppendLine("") $null = $helpBuilder.AppendLine(".DESCRIPTION") $null = $helpBuilder.AppendLine(" 自动生成的帮助文档(基于 AST 解析)") $null = $helpBuilder.AppendLine("")
if ($fn.Parameters) { $null = $helpBuilder.AppendLine(".PARAMETERS") foreach ($param in $fn.Parameters) { $paramName = $param.Name.VariablePath.UserPath $paramType = if ($param.Attributes.TypeName) { $param.Attributes.TypeName.Name } else { "object" } $defaultValue = if ($param.DefaultValue) { $param.DefaultValue.Extent.Text } else { "(必需)" } $null = $helpBuilder.AppendLine(" -$paramName [$paramType] 默认值: $defaultValue") } $null = $helpBuilder.AppendLine("") }
$bodyCmds = $fn.Body.FindAll( { param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true ) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ }
$null = $helpBuilder.AppendLine(".NOTES") $null = $helpBuilder.AppendLine(" 函数行数: $($fn.Extent.EndLineNumber - $fn.Extent.StartLineNumber + 1)") $null = $helpBuilder.AppendLine(" 调用命令: $($bodyCmds -join ', ')") $null = $helpBuilder.AppendLine("#>")
Write-Host "=== $($fn.Name) 的帮助文档 ===" -ForegroundColor Cyan Write-Host $helpBuilder.ToString() } }
'ExtractMetrics' { $totalLines = ($source -split "`n").Count $codeLines = ($source -split "`n" | Where-Object { $_.Trim() -and $_.Trim() -notmatch '^\s*#' -and $_.Trim() -notmatch '^\s*<#' -and $_.Trim() -notmatch '^\s*#>' }).Count $commentLines = $totalLines - $codeLines
$functions = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true ) $allCommands = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true ) $uniqueCommands = $allCommands | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique
$metrics = [PSCustomObject]@{ FilePath = $ScriptPath TotalLines = $totalLines CodeLines = $codeLines CommentLines = $commentLines Functions = $functions.Count CommandCount = $allCommands.Count UniqueCommands = $uniqueCommands.Count CommandList = $uniqueCommands -join ", " ParseErrors = $errors.Count }
Write-Host "=== 脚本度量 ===" -ForegroundColor Cyan $metrics | Format-List } } }
$demoScript = Join-Path $env:TEMP "refactor-demo.ps1" @' function Get-ServiceStatus { param( [string[]]$ComputerName = @("localhost"), [string]$ServiceName = "WinRM" ) foreach ($comp in $ComputerName) { $svc = Get-Service -Name $ServiceName -ComputerName $comp -ErrorAction SilentlyContinue if ($svc) { $output = [PSCustomObject]@{ Computer = $comp Service = $ServiceName Status = $svc.Status } Write-Output $output } } }
$computers = @("SERVER01", "SERVER02", "SERVER03") Get-ServiceStatus -ComputerName $computers -ServiceName "Spooler" '@ | Set-Content -Path $demoScript -Encoding Utf8
Write-Host "--- 演示: 变量重命名 ---" -ForegroundColor Yellow Invoke-CodeRefactor -ScriptPath $demoScript -Operation RenameVariable -OldName "computers" -NewName "serverList"
Write-Host "`n--- 演示: 生成帮助文档 ---" -ForegroundColor Yellow Invoke-CodeRefactor -ScriptPath $demoScript -Operation GenerateHelp
Write-Host "`n--- 演示: 提取度量信息 ---" -ForegroundColor Yellow Invoke-CodeRefactor -ScriptPath $demoScript -Operation ExtractMetrics
Remove-Item -Path $demoScript -Force Remove-Item -Path "$demoScript.bak" -Force -ErrorAction SilentlyContinue
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| --- 演示: 变量重命名 --- 已将 $computers 重命名为 $serverList (共 1 处) 备份文件: /tmp/refactor-demo.ps1.bak
--- 演示: 生成帮助文档 --- === Get-ServiceStatus 的帮助文档 === <# .SYNOPSIS Get-ServiceStatus 函数
.DESCRIPTION 自动生成的帮助文档(基于 AST 解析)
.PARAMETERS -ComputerName [string[]] 默认值: @("localhost") -ServiceName [string] 默认值: "WinRM"
.NOTES 函数行数: 15 调用命令: Get-Service, Write-Output #>
--- 演示: 提取度量信息 --- === 脚本度量 ===
FilePath : /tmp/refactor-demo.ps1 TotalLines : 18 CodeLines : 17 CommentLines : 1 Functions : 1 CommandCount : 3 UniqueCommands : 3 CommandList : Get-Service, Get-ServiceStatus, Write-Output ParseErrors : 0
|
注意事项
AST 是只读的:PowerShell 的 AST 对象模型是只读的,不能直接修改 AST 节点来改变代码。要实现代码修改(如重命名变量),需要在原始源码字符串上基于 AST 提供的位置偏移量进行文本替换,然后重新写入文件。
解析与执行分离:AST 解析仅进行语法分析,不会执行任何代码。这意味着即使脚本中包含危险操作(如 Remove-Item),解析过程也是完全安全的。但也因此无法获取运行时信息,如变量的实际值和类型推断。
Token 与 AST 的区别:ParseInput 同时返回 AST 树和 Token 列表。Token 是词法分析的最小单元(标识符、运算符、字符串等),而 AST 是语法层面的结构化表示。做变量重命名等精确文本替换时应使用 Token 的偏移量,做结构分析时应使用 AST 节点。
嵌套脚本的解析:对于通过 dot-sourcing(. .\script.ps1)或 Invoke-Expression 动态加载的脚本,AST 无法自动追踪。如果需要分析完整的依赖链,必须递归解析所有引用的脚本文件。
性能考虑:对于超大脚本文件(数千行),FindAll() 使用递归遍历可能会产生大量对象。建议在 $predicate 回调中尽可能精确地过滤节点类型,避免不必要的全树遍历。如果只需要顶层元素,可以将第二个参数(searchNestedScriptBlocks)设为 $false。
与 PSScriptAnalyzer 配合:本文演示了如何从零编写 AST 分析逻辑。在实际项目中,建议优先使用 PSScriptAnalyzer 的自定义规则框架,它封装了 AST 遍历的细节,提供了标准化的规则接口和输出格式,与 VS Code 扩展和 CI/CD 流水线的集成也更加方便。