PowerShell 技能连载 - 错误处理模式

适用于 PowerShell 5.1 及以上版本

背景

在编写 PowerShell 脚本时,错误处理往往是被忽视的环节。很多开发者习惯性地依赖默认的错误输出,直到脚本在无人值守的生产环境中意外崩溃才开始重视。PowerShell 提供了丰富的错误处理机制,从简单的 -ErrorAction 参数到完整的 try/catch/finally 结构,再到 $Error 自动变量的深度利用,每一种机制都有其最佳适用场景。

掌握错误处理模式不仅能提升脚本的健壮性,还能让运维人员更快地定位问题根因。本文将介绍三种最常见的错误处理模式:基本的 try/catch 模式、批量操作的容错模式以及错误日志收集模式,帮助你在不同场景下选择最合适的策略。

模式一:基本的 try/catch/finally 模式

try/catch/finally 是最经典的错误处理结构。try 块中放置可能出错的操作,catch 块中处理异常,finally 块无论是否出错都会执行,适合做资源清理。

需要注意,PowerShell 默认的”非终止错误”不会被 catch 捕获。必须将 ErrorAction 设置为 Stop,将非终止错误提升为终止错误,catch 才能生效。

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
function Copy-LogArchive {
param(
[string]$SourcePath,
[string]$DestinationPath
)

try {
Write-Host "开始复制日志归档文件..."
$sourceItem = Get-Item -Path $SourcePath -ErrorAction Stop

if (-not $sourceItem.Exists) {
throw "源文件不存在: $SourcePath"
}

Copy-Item -Path $SourcePath -Destination $DestinationPath -Force -ErrorAction Stop
Write-Host "文件复制成功: $DestinationPath"
}
catch [System.IO.FileNotFoundException] {
Write-Error "文件未找到异常: $($_.Exception.Message)"
}
catch [System.UnauthorizedAccessException] {
Write-Error "权限不足: $($_.Exception.Message)"
}
catch {
Write-Error "未知错误: $($_.Exception.Message)"
Write-Host "异常类型: $($_.Exception.GetType().FullName)"
}
finally {
Write-Host "操作完成,清理临时资源..."
}
}

Copy-LogArchive -SourcePath "C:\Logs\app-2025.zip" -DestinationPath "D:\Backup\app-2025.zip"

执行结果示例:

1
2
3
开始复制日志归档文件...
文件未找到异常: 找不到路径"C:\Logs\app-2025.zip",因为该路径不存在。
操作完成,清理临时资源...

模式二:批量操作的容错模式

在批量处理大量对象时(比如遍历数百台服务器或上千个文件),不希望某一个对象的失败导致整个操作中断。此时应使用 foreach 循环配合 try/catch,让每个对象独立处理,并记录成功与失败的统计信息。

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
$computers = @(
"SRV-WEB01",
"SRV-DB01",
"SRV-APP01",
"SRV-FILE01",
"SRV-MAIL01"
)

$successList = [System.Collections.Generic.List[string]]::new()
$failList = [System.Collections.Generic.List[string]]::new()

foreach ($computer in $computers) {
try {
$session = New-PSSession -ComputerName $computer -ErrorAction Stop

$result = Invoke-Command -Session $session -ScriptBlock {
Get-Service -Name "WinRM" | Select-Object Name, Status, StartType
} -ErrorAction Stop

Write-Host "[$computer] WinRM 状态: $($result.Status)" -ForegroundColor Green
$successList.Add($computer)

Remove-PSSession -Session $session
}
catch {
$errorMsg = $_.Exception.Message
Write-Host "[$computer] 连接失败: $errorMsg" -ForegroundColor Red
$failList.Add("${computer}: ${errorMsg}")
}
}

Write-Host "`n===== 批量操作汇总 ====="
Write-Host "成功: $($successList.Count) 台"
Write-Host "失败: $($failList.Count) 台"

if ($failList.Count -gt 0) {
Write-Host "`n失败详情:" -ForegroundColor Yellow
foreach ($item in $failList) {
Write-Host " - $item"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
[SRV-WEB01] WinRM 状态: Running
[SRV-DB01] 连接失败: 连接到远程服务器 SRV-DB01 失败。
[SRV-APP01] WinRM 状态: Running
[SRV-FILE01] 连接失败: 拒绝访问。
[SRV-MAIL01] WinRM 状态: Stopped

===== 批量操作汇总 =====
成功: 3 台
失败: 2 台

失败详情:
- SRV-DB01: 连接到远程服务器 SRV-DB01 失败。
- SRV-FILE01: 拒绝访问。

模式三:结构化错误日志收集模式

在复杂脚本或自动化流水线中,仅靠控制台输出远远不够。我们需要将错误信息以结构化方式记录下来,便于后续分析和审计。下面的模式使用自定义错误记录对象,在脚本执行结束后统一输出 JSON 格式的错误报告。

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
$logEntries = [System.Collections.Generic.List[PSObject]]::new()

function Write-OperationLog {
param(
[string]$Operation,
[string]$Target,
[string]$Status,
[string]$Message = ""
)

$entry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Operation = $Operation
Target = $Target
Status = $Status
Message = $Message
}

$logEntries.Add($entry)
}

$services = @("Spooler", "WinRM", "wuauserv", "NonExistSvc", "BITS")

foreach ($svc in $services) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
Restart-Service -InputObject $service -Force -ErrorAction Stop
Write-OperationLog -Operation "Restart-Service" -Target $svc -Status "Success"
Write-Host "[OK] $svc 已重启" -ForegroundColor Green
}
catch {
Write-OperationLog -Operation "Restart-Service" -Target $svc -Status "Failed" -Message $_.Exception.Message
Write-Host "[FAIL] $svc 失败: $($_.Exception.Message)" -ForegroundColor Red
}
}

$report = $logEntries | ConvertTo-Json -Depth 3
$reportPath = Join-Path $env:TEMP "service-operation-report.json"
$report | Out-File -FilePath $reportPath -Encoding UTF8

Write-Host "`n报告已保存至: $reportPath"
Write-Host "总记录数: $($logEntries.Count)"
Write-Host "失败记录: $(($logEntries | Where-Object { $_.Status -eq 'Failed' }).Count)"

执行结果示例:

1
2
3
4
5
6
7
8
9
[OK] Spooler 已重启
[OK] WinRM 已重启
[FAIL] wuauserv 失败: 无法打开"Windows Update"服务。
[FAIL] NonExistSvc 失败: 找不到任何服务名称为"NonExistSvc"的服务。
[OK] BITS 已重启

报告已保存至: /tmp/service-operation-report.json
总记录数: 5
失败记录: 2

模式四:错误类型筛选与重试模式

在生产环境中,某些操作可能因瞬时故障(如网络抖动、数据库连接超时)而失败,这种情况下自动重试比直接报错更合理。下面的模式根据异常类型决定是否重试,并设置最大重试次数和退避间隔。

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
function Invoke-WebRequestWithRetry {
param(
[string]$Url,
[int]$MaxRetries = 3,
[int]$RetryIntervalSeconds = 2
)

$attempt = 0
$lastError = $null

while ($attempt -lt $MaxRetries) {
$attempt++
try {
Write-Host "第 $attempt 次请求: $Url"
$response = Invoke-WebRequest -Uri $Url -TimeoutSec 10 -ErrorAction Stop
Write-Host "请求成功,状态码: $($response.StatusCode)" -ForegroundColor Green
return $response
}
catch [System.Net.WebException] {
$lastError = $_
$statusCode = $_.Exception.Response.StatusCode

if ($statusCode -ge 400 -and $statusCode -lt 500) {
Write-Host "客户端错误 ($statusCode),不重试。" -ForegroundColor Red
throw $_
}

Write-Host "服务端/网络错误,${RetryIntervalSeconds}s 后重试..." -ForegroundColor Yellow
Start-Sleep -Seconds $RetryIntervalSeconds
$RetryIntervalSeconds = $RetryIntervalSeconds * 2
}
catch {
$lastError = $_
Write-Host "非网络异常: $($_.Exception.Message)" -ForegroundColor Red
throw $_
}
}

Write-Host "已达最大重试次数 ($MaxRetries),操作失败。" -ForegroundColor Red
throw $lastError
}

Invoke-WebRequestWithRetry -Url "https://httpbin.org/status/500" -MaxRetries 3

执行结果示例:

1
2
3
4
5
6
7
1 次请求: https://httpbin.org/status/500
服务端/网络错误,2s 后重试...
2 次请求: https://httpbin.org/status/500
服务端/网络错误,4s 后重试...
3 次请求: https://httpbin.org/status/500
已达最大重试次数 (3),操作失败。
Invoke-WebRequestWithRetry: 远程服务器返回错误: (500) 内部服务器错误。

注意事项

  1. 区分终止错误与非终止错误:PowerShell 中大多数 cmdlet 产生的是非终止错误(Non-Terminating Error),默认不会触发 catch。务必在 cmdlet 后添加 -ErrorAction Stop 参数,或通过 $ErrorActionPreference = 'Stop' 全局设置,才能让 try/catch 正常工作。

  2. 不要滥用空 catch 块:空的 catch {} 会静默吞掉所有错误,让排错变得极其困难。即使在不需要特殊处理的场景下,也应至少记录一行日志,比如 Write-Warning "操作失败: $($_.Exception.Message)"

  3. 善用 catch 的类型过滤catch 可以指定具体的异常类型(如 [System.IO.FileNotFoundException]),先捕获具体异常,最后用通用的 catch {} 兜底。多个 catch 块按照从具体到通用的顺序排列。

  4. finally 块用于资源释放:无论是否发生异常,finally 块都会执行。适合关闭数据库连接、移除临时文件、释放 PSSession 等清理工作。即使 try 中有 return 语句,finally 也会在返回前执行。

  5. $Error 自动变量保留历史记录:PowerShell 的 $Error 数组自动收集所有会话中的错误,最新错误在索引 0。可以通过 $Error[0] | Format-List * 查看完整的错误详情,包括脚本堆栈追踪(ScriptStackTrace)。

  6. 批量操作避免管道中的 try/catch:在管道(|)中使用 ForEach-Object 嵌套 try/catch 会导致代码难以阅读和调试。推荐改用 foreach 语句,结构更清晰,也更容易添加计数器和日志收集逻辑。

PowerShell 技能连载 - 错误处理设计模式

适用于 PowerShell 5.1 及以上版本

“程序能工作”和”程序能可靠地工作”之间的差距,往往就在于错误处理。PowerShell 的错误处理机制比大多数脚本语言更丰富——有终止错误和非终止错误的区分、有 $ErrorActionPreference 全局设置、有 try/catch/finally 结构、有 -ErrorVariable 参数。理解这些机制并设计合理的错误处理模式,是编写生产级脚本的关键。

本文将系统讲解 PowerShell 的错误处理机制、常见设计模式,以及如何构建可靠的自动化脚本。

错误类型与控制

PowerShell 有两类错误:终止错误(Terminating Error,会停止执行)和非终止错误(Non-Terminating Error,默认只记录继续执行):

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
# 非终止错误:默认不会停止脚本
Get-Content "不存在的文件.txt" -ErrorAction SilentlyContinue
Write-Host "这行会执行" # 即使上面报错

# 将非终止错误转为终止错误(可以被 try/catch 捕获)
try {
Get-Content "不存在的文件.txt" -ErrorAction Stop
} catch {
Write-Host "捕获到错误:$($_.Exception.Message)" -ForegroundColor Red
}

# 全局错误偏好
$ErrorActionPreference = 'Stop' # 所有非终止错误都变成终止错误

# 使用 -ErrorVariable 收集错误
$erroredItems = @()
Get-ChildItem "C:\SomePath" -Recurse -ErrorVariable +erroredItems -ErrorAction SilentlyContinue

if ($erroredItems) {
Write-Host "遇到 $($erroredItems.Count) 个错误:" -ForegroundColor Yellow
$erroredItems | ForEach-Object { Write-Host " $($_.Exception.Message)" }
}

# $error 自动变量(最近发生的错误列表)
$error | Select-Object -First 5 |
ForEach-Object { Write-Host $_.Exception.Message }

执行结果示例:

1
2
3
4
5
捕获到错误:找不到路径"C:\不存在的文件.txt"的路径
遇到 3 个错误:
拒绝访问路径 C:\Windows\System32\config
找不到驱动器名为 "Z" 的驱动器
拒绝访问路径 C:\Users\admin\NTUSER.DAT

try/catch/finally 模式

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
# 基础模式:捕获并记录
function Copy-FileSafe {
param([string]$Source, [string]$Destination)

try {
Copy-Item -Path $Source -Destination $Destination -Force -ErrorAction Stop
Write-Host "已复制:$Source => $Destination" -ForegroundColor Green
} catch {
Write-Host "复制失败:$($_.Exception.Message)" -ForegroundColor Red
return $false
}
return $true
}

# finally 模式:资源清理
function Invoke-DatabaseOperation {
param([string]$ConnectionString)

$connection = $null
try {
$connection = New-Object System.Data.SqlClient.SqlConnection($ConnectionString)
$connection.Open()
Write-Host "数据库连接已建立" -ForegroundColor Green

$command = $connection.CreateCommand()
$command.CommandText = "SELECT COUNT(*) FROM Users"
$result = $command.ExecuteScalar()
Write-Host "用户数量:$result"

} catch {
Write-Host "数据库操作失败:$($_.Exception.Message)" -ForegroundColor Red
throw # 重新抛出,让调用者处理
} finally {
# 无论成功失败,始终清理资源
if ($connection -and $connection.State -eq 'Open') {
$connection.Close()
Write-Host "数据库连接已关闭" -ForegroundColor DarkGray
}
}
}

# 嵌套错误处理
function Deploy-Application {
param([string]$AppName)

try {
Write-Host "开始部署:$AppName" -ForegroundColor Cyan

try {
Stop-Service $AppName -ErrorAction Stop
} catch {
Write-Host "服务未运行,跳过停止" -ForegroundColor Yellow
}

# 更新文件
Copy-Item "C:\Releases\$AppName\*" "D:\Apps\$AppName\" -Recurse -Force

Start-Service $AppName
Write-Host "部署完成:$AppName" -ForegroundColor Green

} catch {
Write-Host "部署失败,回滚中..." -ForegroundColor Red
# 回滚逻辑
try {
Start-Service $AppName -ErrorAction SilentlyContinue
} catch {}
throw "部署失败:$($_.Exception.Message)"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
已复制:C:\Data\file.txt => D:\Backup\file.txt

数据库连接已建立
用户数量:85234
数据库连接已关闭

开始部署:MyApp
服务未运行,跳过停止
部署完成:MyApp

重试模式

网络操作和外部 API 调用经常因临时故障失败,重试模式是必备的:

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
function Invoke-WithRetry {
<#
.SYNOPSIS
带指数退避重试的执行包装器
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,

[int]$MaxRetries = 3,
[int]$BaseDelaySeconds = 2,
[double]$BackoffMultiplier = 2.0,
[string]$Description = "操作"
)

$attempt = 0
$lastError = $null

while ($attempt -lt $MaxRetries) {
$attempt++
try {
$result = & $ScriptBlock
if ($attempt -gt 1) {
Write-Host "$Description 在第 $attempt 次尝试成功" -ForegroundColor Green
}
return $result
} catch {
$lastError = $_

if ($attempt -ge $MaxRetries) {
Write-Host "$Description 失败,已重试 $MaxRetries 次" -ForegroundColor Red
throw
}

$delay = $BaseDelaySeconds * [Math]::Pow($BackoffMultiplier, $attempt - 1)
Write-Host "$description$attempt 次失败,${delay}秒后重试..." -ForegroundColor Yellow
Start-Sleep -Seconds $delay
}
}
}

# 使用重试模式调用 API
$result = Invoke-WithRetry -Description "调用 GitHub API" -MaxRetries 3 -ScriptBlock {
Invoke-RestMethod -Uri "https://api.github.com/rate_limit" -ErrorAction Stop
}

# 重试文件操作
Invoke-WithRetry -Description "复制大文件" -ScriptBlock {
Copy-Item "\\server\share\large-file.zip" "C:\Downloads\" -Force -ErrorAction Stop
}

执行结果示例:

1
2
调用 GitHub API 第 1 次失败,2秒后重试...
调用 GitHub API 在第 2 次尝试成功

错误日志记录

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
function Write-ErrorLog {
<#
.SYNOPSIS
将错误信息写入日志文件
#>
param(
[Parameter(Mandatory)]
[System.Management.Automation.ErrorRecord]$ErrorRecord,

[string]$LogPath = "C:\Logs\errors.log",
[string]$Context
)

$entry = [PSCustomObject]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Context = $Context
Message = $ErrorRecord.Exception.Message
Category = $ErrorRecord.CategoryInfo.Category
Target = $ErrorRecord.TargetObject
Script = $ErrorRecord.InvocationInfo.ScriptName
Line = $ErrorRecord.InvocationInfo.ScriptLineNumber
}

$entry | Export-Csv -Path $LogPath -Append -NoTypeInformation -Encoding UTF8
}

# 使用示例
try {
Get-Content "不存在的文件.txt" -ErrorAction Stop
} catch {
Write-ErrorLog -ErrorRecord $_ -Context "读取配置文件"
Write-Host "错误已记录到日志" -ForegroundColor Yellow
}

执行结果示例:

1
错误已记录到日志

注意事项

  1. 区分错误类型-ErrorAction Stop 将非终止错误转为终止错误,这是让 try/catch 生效的关键
  2. 不要吞掉错误:空 catch 块 {} 会隐藏所有错误信息,至少记录日志
  3. finally 始终执行:无论是否发生异常、是否有 return 语句,finally 块都会执行
  4. $ErrorView:PowerShell 7 中 $ErrorView = 'ConciseView' 提供更简洁的错误输出
  5. 错误记录对象$_ 在 catch 块中是 ErrorRecord 对象,包含 ExceptionCategoryInfoInvocationInfo 等详细信息
  6. throw vs Write-Errorthrow 创建终止错误,Write-Error 创建非终止错误。在函数中选择合适的错误类型