适用于 PowerShell 7.0 及以上版本
在编写生产级 PowerShell 脚本时,错误处理和日志记录往往是最容易被忽视、却又最为关键的环节。一段没有错误处理的脚本,在遇到网络超时、文件缺失或权限不足时,要么静默失败导致后续逻辑产生难以排查的连锁错误,要么直接崩溃让整个自动化流程中断。而缺乏日志的脚本则如同”黑盒”——出了问题只能靠猜测,无法追溯根因。
PowerShell 提供了丰富的错误处理机制,从基础的 $ErrorActionPreference 到完善的 try/catch/finally 结构,再到自定义错误记录和结构化日志输出。本文将系统性地介绍这些机制,并给出带重试逻辑的健壮脚本模板,帮助你在生产环境中写出更可靠的自动化代码。
终止错误与非终止错误的区别 PowerShell 将错误分为两类:终止错误(Terminating Error) 和非终止错误(Non-Terminating Error) 。理解这两者的区别是正确使用错误处理机制的前提。
终止错误 :由 throw 语句、Write-Error -ErrorAction Stop 或 Cmdlet 的致命故障产生,会中断当前管道和脚本的执行,触发 catch 块。
非终止错误 :大多数 Cmdlet 在遇到可恢复问题(如删除一个不存在的文件)时产生,默认只写入错误流,脚本继续执行,不会触发 catch 块。
下面的示例演示了两种错误的行为差异:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Write-Host "=== 非终止错误演示 ===" Remove-Item -Path "C:\NonExistentFile.txt" -ErrorAction Continue Write-Host "脚本继续执行,非终止错误不会中断流程" Write-Host "`n=== 终止错误演示 ===" try { throw "这是一个终止错误,执行将被中断" Write-Host "这行不会执行" } catch { Write-Host "捕获到终止错误: $ ($_ .Exception.Message)" }
1 2 3 4 5 6 === 非终止错误演示 === Remove-Item: Cannot find path 'C:\NonExistentFile.txt' because it does not exist. 脚本继续执行,非终止错误不会中断流程 === 终止错误演示 === 捕获到终止错误: 这是一个终止错误,执行将被中断
可以看到,非终止错误只是输出了一条红色警告,脚本依然继续往下跑;而终止错误则直接跳入了 catch 块,throw 之后的代码不会执行。
$ErrorActionPreference 与 -ErrorAction 参数 $ErrorActionPreference 是一个全局偏好变量,控制 PowerShell 对非终止错误的默认处理方式。而 -ErrorAction(缩写 -EA)是 Cmdlet 的通用参数,可以针对单条命令进行覆盖。
常用的值包括:SilentlyContinue(静默忽略)、Continue(默认,输出错误但继续)、Stop(将非终止错误升级为终止错误,触发 catch)和Ignore(完全忽略且不写入错误流)。
下面的示例展示了如何通过 -ErrorAction Stop 将非终止错误转为终止错误,从而被 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 Write-Host "=== 默认行为 ===" try { Get-Item -Path "C:\NoSuchFolder\file.txt" Write-Host "这行会执行,因为默认是非终止错误" } catch { Write-Host "不会进入这里" } Write-Host "`n=== 使用 -ErrorAction Stop ===" try { Get-Item -Path "C:\NoSuchFolder\file.txt" -ErrorAction Stop Write-Host "这行不会执行" } catch { Write-Host "成功捕获: $ ($_ .Exception.Message)" } $oldPref = $ErrorActionPreference $ErrorActionPreference = 'Stop' Write-Host "`n=== 全局 Stop 偏好 ===" try { Get-Process -Name "NonExistentProcess" -ErrorAction SilentlyContinue Write-Host "单条命令 -EA SilentlyContinue 仍可覆盖全局设置" } catch { Write-Host "不会进入这里" } $ErrorActionPreference = $oldPref
1 2 3 4 5 6 7 8 9 === 默认行为 === Get-Item: Cannot find path 'C:\NoSuchFolder\file.txt' because it does not exist. 这行会执行,因为默认是非终止错误 === 使用 -ErrorAction Stop === 成功捕获: Cannot find path 'C:\NoSuchFolder\file.txt' because it does not exist. === 全局 Stop 偏好 === 单条命令 -EA SilentlyContinue 仍可覆盖全局设置
最佳实践 :在需要精确控制错误行为时,优先使用单条命令的 -ErrorAction 参数,而非全局修改 $ErrorActionPreference。全局修改会影响作用域内所有命令,可能产生意料之外的副作用。
try/catch/finally 完整结构 try/catch/finally 是 PowerShell 中最结构化的错误处理方式。try 块中放置可能出错的代码,catch 块处理错误,finally 块无论是否出错都会执行,通常用于资源清理。
一个常见的误区是:catch 只能捕获终止错误。如果你希望捕获某个 Cmdlet 的非终止错误,必须配合 -ErrorAction Stop 使用。
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 function Copy-LogFiles { param ( [string ]$SourcePath , [string ]$DestinationPath ) try { if (-not (Test-Path -Path $SourcePath )) { throw "源目录不存在: $SourcePath " } if (-not (Test-Path -Path $DestinationPath )) { New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null Write-Host "已创建目标目录: $DestinationPath " } $files = Get-ChildItem -Path $SourcePath -Filter "*.log" ` -ErrorAction Stop $count = 0 foreach ($file in $files ) { Copy-Item -Path $file .FullName -Destination $DestinationPath ` -Force -ErrorAction Stop $count ++ } Write-Host "成功复制 $count 个日志文件" } catch { Write-Error "复制日志文件失败: $ ($_ .Exception.Message)" throw $_ } finally { $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "[$timestamp ] Copy-LogFiles 操作结束" } } Copy-LogFiles -SourcePath "C:\NoLogs" -DestinationPath "C:\Backup"
1 2 [2025 -04 -28 10 :30 :00 ] Copy -LogFiles 操作结束 Copy -LogFiles: 复制日志文件失败: 源目录不存在: C:\NoLogs
注意 finally 块在 throw 之前执行了——即使脚本因为 throw 而中断,finally 中的清理逻辑仍然会运行。这是释放数据库连接、关闭文件句柄等资源清理工作的理想位置。
自定义错误记录与错误视图 PowerShell 的 $Error 自动变量维护了一个错误列表(最近发生的错误在前)。通过 ErrorRecord 对象,我们可以获取丰富的错误上下文信息。此外,PowerShell 允许我们创建自定义的错误记录,以便在模块和大型脚本中提供更精确的错误分类。
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 try { Get-Process -Name "DefinitelyNotARealProcess" -ErrorAction Stop } catch { $err = $_ Write-Host "错误类型: $ ($err .CategoryInfo.Category)" Write-Host "错误原因: $ ($err .CategoryInfo.Reason)" Write-Host "目标对象: $ ($err .TargetObject)" Write-Host "完整消息: $ ($err .Exception.Message)" Write-Host "脚本堆栈: $ ($err .ScriptStackTrace)" } Write-Host "`n=== 自定义错误记录 ===" $exception = [System.InvalidOperationException ]::new( "配置文件版本不兼容,要求 v2.0,实际 v1.3" ) $errorRecord = [System.Management.Automation.ErrorRecord ]::new( $exception , "ConfigVersionMismatch" , [System.Management.Automation.ErrorCategory ]::InvalidData, "config-v1.3" ) Write-Host "自定义 ErrorId: $ ($errorRecord .FullyQualifiedErrorId)" Write-Host "错误类别: $ ($errorRecord .CategoryInfo.Category)" Write-Host "目标对象: $ ($errorRecord .TargetObject)"
1 2 3 4 5 6 7 8 9 10 错误类型: ObjectNotFound 错误原因: Get-Process 目标对象: DefinitelyNotARealProcess 完整消息: Cannot find a process with the name 'DefinitelyNotARealProcess' . 脚本堆栈: at <ScriptBlock>, <No file>: line 2 === 自定义错误记录 === 自定义 ErrorId: ConfigVersionMismatch 错误类别: InvalidData 目标对象: config-v1.3
自定义错误记录在编写可复用模块时特别有用。通过 FullyQualifiedErrorId,调用方可以精确地针对特定错误类型编写处理逻辑,而非依赖模糊的字符串匹配。
结构化日志输出 在生产环境中,零散的 Write-Host 输出很难被日志收集系统(如 ELK、Splunk)解析。结构化日志(Structured Logging)将每条日志输出为 JSON 格式,便于集中存储、检索和告警。
下面的函数封装了一个轻量级的结构化日志写入器,支持控制台彩色输出和 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 44 45 46 47 48 49 50 function Write-StructuredLog { param ( [Parameter (Mandatory )] [string ]$Message , [ValidateSet ('INFO' , 'WARN' , 'ERROR' , 'DEBUG' )] [string ]$Level = 'INFO' , [string ]$LogPath = "$PSScriptRoot \app.log.json" , [hashtable ]$ExtraData = @ {} ) $entry = [ordered ]@ { timestamp = (Get-Date ).ToString("o" ) level = $Level message = $Message script = $MyInvocation .ScriptName line = $MyInvocation .ScriptLineNumber } foreach ($key in $ExtraData .Keys) { $entry [$key ] = $ExtraData [$key ] } $jsonLine = $entry | ConvertTo-Json -Compress $color = switch ($Level ) { 'ERROR' { 'Red' } 'WARN' { 'Yellow' } 'DEBUG' { 'Gray' } default { 'White' } } Write-Host "[$Level ] $Message " -ForegroundColor $color $jsonLine | Add-Content -Path $LogPath -Encoding UTF8 } Write-StructuredLog -Message "开始执行数据同步" -Level 'INFO' ` -ExtraData @ { source = "DB-01" ; records = 1500 } Write-StructuredLog -Message "连接超时,准备重试" -Level 'WARN' ` -ExtraData @ { server = "api.example.com" ; attempt = 1 } Write-StructuredLog -Message "数据校验失败" -Level 'ERROR' ` -ExtraData @ { table = "Orders" ; failedRows = 23 }
1 2 3 [INFO ] 开始执行数据同步 [WARN] 连接超时,准备重试 [ERROR ] 数据校验失败
日志文件中每行是一个独立的 JSON 对象,这种格式(JSON Lines / NDJSON)可以直接被 jq、ConvertFrom-Json 或任何日志平台解析,无需处理嵌套数组结构。
带重试逻辑的健壮脚本模板 在真实的运维场景中,网络抖动、服务暂时不可用等问题是常态。一个健壮的脚本应该具备自动重试能力,而不是遇到第一次失败就放弃。下面的模板将 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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 function Invoke-WithRetry { param ( [Parameter (Mandatory )] [scriptblock ]$Action , [string ]$Description = "操作" , [int ]$MaxRetries = 3 , [int ]$BaseDelaySeconds = 2 , [double ]$BackoffMultiplier = 2.0 ) $attempt = 0 $lastError = $null while ($attempt -lt $MaxRetries ) { $attempt ++ try { Write-Host "[$attempt /$MaxRetries ] 正在执行: $Description " $result = & $Action Write-Host "[$attempt /$MaxRetries ] 执行成功" ` -ForegroundColor Green return $result } catch { $lastError = $_ $delay = $BaseDelaySeconds * [Math ]::Pow($BackoffMultiplier , $attempt - 1 ) Write-Host ("[$attempt /$MaxRetries ] 执行失败: " + "$ ($_ .Exception.Message)" ) -ForegroundColor Red if ($attempt -lt $MaxRetries ) { Write-Host " 等待 $delay 秒后重试..." ` -ForegroundColor Yellow Start-Sleep -Seconds $delay } } } Write-Host "已达到最大重试次数 ($MaxRetries ),操作终止" ` -ForegroundColor Red throw $lastError } $result = Invoke-WithRetry -Description "调用用户数据接口" ` -MaxRetries 3 -BaseDelaySeconds 2 -BackoffMultiplier 2.0 ` -Action { $response = Invoke-RestMethod ` -Uri "https://api.example.com/users" ` -TimeoutSec 10 ` -ErrorAction Stop return $response } Write-Host "获取到 $ ($result .Count) 条用户记录"
1 2 3 4 5 6 7 8 9 [1 /3 ] 正在执行: 调用用户数据接口 [1 /3 ] 执行失败: The operation has timed out. 等待 2 秒后重试... [2 /3 ] 正在执行: 调用用户数据接口 [2 /3 ] 执行失败: The operation has timed out. 等待 4 秒后重试... [3 /3 ] 正在执行: 调用用户数据接口 [3 /3 ] 执行成功 获取到 42 条用户记录
指数退避(Exponential Backoff)的等待时间递增规律为:2s、4s、8s……这样做的好处是给远程服务足够的恢复时间,同时在首次失败后不会等太久。如果所有重试都失败,最后的 throw 确保错误会向上传递,调用方可以决定是否继续或中止整个流程。
综合实战:带日志和重试的文件同步脚本 将前面介绍的所有技术整合起来,下面是一个完整的文件同步脚本,涵盖结构化日志、try/catch/finally、-ErrorAction Stop 和重试逻辑:
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 function Sync-LogDirectory { param ( [Parameter (Mandatory )] [string ]$Source , [Parameter (Mandatory )] [string ]$Destination , [int ]$MaxRetries = 3 ) $syncResult = [ordered ]@ { startTime = $null endTime = $null copiedFiles = 0 failedFiles = 0 skippedFiles = 0 } try { $syncResult .startTime = Get-Date -Format "o" if (-not (Test-Path -Path $Source -ErrorAction Stop)) { throw "源路径不存在: $Source " } $null = New-Item -ItemType Directory -Path $Destination ` -Force -ErrorAction Stop $files = Get-ChildItem -Path $Source -Filter "*.log" ` -Recurse -ErrorAction Stop foreach ($file in $files ) { $destFile = Join-Path $Destination $file .Name if ((Test-Path $destFile ) -and ($file .LastWriteTime -le (Get-Item $destFile ).LastWriteTime)) { $syncResult .skippedFiles++ continue } try { Invoke-WithRetry -Description "复制 $ ($file .Name)" ` -MaxRetries $MaxRetries -Action { Copy-Item -Path $file .FullName ` -Destination $destFile -Force ` -ErrorAction Stop } $syncResult .copiedFiles++ } catch { Write-Host "跳过文件 $ ($file .Name): " + "$ ($_ .Exception.Message)" -ForegroundColor Yellow $syncResult .failedFiles++ } } } catch { Write-Host "同步过程中发生致命错误: " + "$ ($_ .Exception.Message)" -ForegroundColor Red throw } finally { $syncResult .endTime = Get-Date -Format "o" Write-Host "`n--- 同步报告 ---" Write-Host ("开始时间: $ ($syncResult .startTime)" ) Write-Host ("结束时间: $ ($syncResult .endTime)" ) Write-Host ("复制成功: $ ($syncResult .copiedFiles) 个文件" ) Write-Host ("复制失败: $ ($syncResult .failedFiles) 个文件" ) Write-Host ("跳过文件: $ ($syncResult .skippedFiles) 个文件" ) } } Sync-LogDirectory -Source "C:\Logs\App" ` -Destination "D:\Backup\Logs"
1 2 3 4 5 6 7 8 9 10 11 [1/3] 正在执行: 复制 app-2025 -04 -27 .log [1/3] 执行成功 [1/3] 正在执行: 复制 app-2025 -04 -28 .log [1/3] 执行成功 --- 同步报告 --- 开始时间: 2025-04 -28 T10:15:00.0000000 结束时间: 2025-04 -28 T10:15:03.0000000 复制成功: 2 个文件 复制失败: 0 个文件 跳过文件: 5 个文件
这个脚本的核心设计思路是:外层 try/catch 处理整体流程的致命错误(如源路径不存在),内层 try/catch 处理单个文件的可恢复错误(跳过失败文件继续处理其余文件),finally 始终输出统计报告。这种”外层致命、内层宽容”的错误处理策略在批量操作场景中非常实用。
注意事项
-ErrorAction Stop 是连接非终止错误与 try/catch 的桥梁 :忘记加这个参数是最常见的错误处理遗漏,导致 catch 块形同虚设。
避免在 catch 中吞掉错误 :如果 catch 块只是 Write-Host 而不 throw,调用方将无法感知失败。除非你明确要忽略某个已知错误,否则应当重新抛出。
$Error 列表会不断增长 :PowerShell 的 $Error 自动变量最多保留 256 条(可通过 $MaximumErrorCount 调整),在长时间运行的脚本中注意不要过度依赖它的顺序。
结构化日志的文件写入考虑并发 :多进程同时写入同一个日志文件可能导致内容交错。在高并发场景下,考虑使用文件锁或集中式日志服务。
重试次数和退避策略需要根据场景调整 :网络请求适合短间隔多次重试,数据库操作可能需要更长间隔。盲目重试可能加剧服务端压力。
finally 块中避免抛出异常 :如果 finally 中的代码也可能失败,务必用嵌套的 try/catch 保护,否则会掩盖原始错误。