很多朋友在写PowerShell脚本的时候,都会遇到一个奇怪的现象:明明在Try块里放了可能出错的命令,Catch里也写好了处理逻辑,可程序却像没看见一样,该报错还是报错,该中断还是中断。这到底是为什么?其实,很多时候问题出在PowerShell错误处理机制里一个叫ErrorActionPreference的全局变量上。它就像整个脚本的“脾气开关”,悄悄影响着TryCatchFinally的每一个动作。今天我们就把这个开关和TryCatchFinally之间的关系彻底捋一捋。

一、先把两种错误分清楚

1.1 非终止型错误:小磕小碰

PowerShell里的命令出错,并不总是“致命”的。比如你想读取一个不存在的文件夹,PowerShell会往屏幕上输出一行红色的错误信息,但脚本不会因此停下来,后面的代码还会继续跑。这种错误叫作非终止型错误。它就像你走路踢到一块小石头,身体晃了一下,但继续往前走。

# 技术栈:PowerShell

# 故意访问一个不存在的路径
Get-ChildItem -Path "C:\NoSuchFolder"

# 因为错误是非终止型的,所以下面这句话照样会执行
Write-Host "我还活着"

你在控制台运行上面这段代码,会先看到一条错误提示,然后看到“我还活着”。这就是非终止型错误的特点:它只是给你提个醒,不打断你的脚本。

1.2 终止型错误:天塌下来了

另一种错误是终止型错误。遇到这种错误,脚本会立刻停下来,不再执行后面的语句。比如你调用一个根本不存在的方法,或者命令本身抛出的严重异常,都属于这一类。TryCatchFinally真正要抓的,就是这种“天塌下来”的错误。

二、TryCatchFinally 到底在干嘛

2.1 Try块和Catch块的默认关系

TryCatchFinally的结构本身并不复杂:Try块里放你想尝试执行的代码;如果发生终止型错误,就跳到Catch块执行处理逻辑;不管有没有出错,Finally块里的代码最终都会执行,通常用来做清理工作。

但这里有一个关键点:TryCatch默认只对终止型错误有效。如果你的命令只产生了一个非终止型错误,TryCatch会直接放它过去,Catch块根本不会被触发。请看下面这个例子。

# 技术栈:PowerShell

try {
    # 这会抛出一个非终止型错误
    Get-ChildItem -Path "C:\NoSuchFolder"
    Write-Host "这句话还是会打印"
} catch {
    Write-Host "我本以为能捕获到错误,结果没戏"
} finally {
    Write-Host "Finally 永远会跑"
}

在默认设置下,运行这段代码,你会发现“这句话还是会打印”被输出了,Catch里的内容一个字都不会出现。是不是有点颠覆认知?很多初学者在这里就栽了跟头。

2.2 ErrorActionPreference 的出现

为了让TryCatch能捕获这些非终止型错误,PowerShell提供了ErrorActionPreference这个全局变量。你可以把它理解为整个脚本的默认错误处理策略。它的默认值是“Continue”,意思是非终止型错误只要把错误信息打印出来,然后继续执行就行了。

如果把ErrorActionPreference改成“Stop”,那么所有非终止型错误都会被“升级”成终止型错误。这样,它们就进入了TryCatch的捕获范围。看下面的例子。

# 技术栈:PowerShell

# 先保存原始配置
$originalPreference = $ErrorActionPreference

# 把全局偏好改成 Stop
$ErrorActionPreference = "Stop"

try {
    # 这里本来是非终止型错误,现在被升级了
    Get-ChildItem -Path "C:\NoSuchFolder"
    Write-Host "这句话不会打印了"
} catch {
    Write-Host "成功了,我捕获到了:$_"
} finally {
    # 恢复原始配置
    $ErrorActionPreference = $originalPreference
    Write-Host "清理工作完成"
}

运行这段代码,你会看到Catch块被触发,而“这句话不会打印了”没有输出。这说明在“Stop”模式下,非终止错误确实被try/catch接管了。

三、协同陷阱面面观

3.1 陷阱一:全局变量被悄悄改动

PowerShell脚本是可以嵌套的。你写的函数或者别人写的模块,很可能在内部修改了$ErrorActionPreference。如果它修改完没有恢复,那么你的脚本就会像被换了脾气的醉汉,行为完全变了。比如一个函数把全局偏好改成了Stop,你后面所有try/catch处理逻辑都会跟着改变,这种隐藏的依赖非常容易踩坑。

# 技术栈:PowerShell

function Set-BadGlobalPreference {
    # 故意只修改,不恢复(这是反面教材)
    $ErrorActionPreference = "Stop"
}

Write-Host "调用前的全局偏好:$ErrorActionPreference"
Set-BadGlobalPreference
Write-Host "调用后的全局偏好:$ErrorActionPreference"

# 从此以后,你的脚本行为就和之前不一样了
try {
    Get-Item "C:\NoSuchFolder"
} catch {
    Write-Host "现在居然能捕获了"
}

3.2 陷阱二:命令级参数压过全局偏好

每个命令都有个-ErrorAction参数,这个参数的优先级比全局变量高。也就是说,即使你把全局偏好设成了Stop,某个命令如果自己写了-ErrorAction Continue,它依然会按照Continue来执行,不升级错误类型。这会导致你的Catch块以为没有错误发生,但实际上错误早就被那个命令“压”下去了。

# 技术栈:PowerShell

$originalPreference = $ErrorActionPreference
$ErrorActionPreference = "Stop"

try {
    # 尽管全局是 Stop,但这个命令自己要求 Continue
    Get-ChildItem -Path "C:\NoSuchFolder" -ErrorAction Continue
    Write-Host "命令自己的设置优先,所以这里还会打印"
} catch {
    Write-Host "Catch 根本不会执行"
} finally {
    $ErrorActionPreference = $originalPreference
}

运行这段代码,你会发现Catch没有执行,“命令自己的设置优先,所以这里还会打印”却输出了。这就是命令级参数的干扰。

3.3 陷阱三:用了 Ignore 或 SilentlyContinue 就把错误藏起来了

有些朋友为了图省事,会在命令后面加-ErrorAction SilentlyContinue,甚至Ignore。这样错误信息不显示了,脚本也不会中断。但如果你把这个命令放在TryCatch里,问题就来了:错误被完全忽略,Catch块自然也不会触发。你以为有TryCatch兜底,实际上兜底被你自己拆了。

# 技术栈:PowerShell

try {
    # 这里用了 Ignore,错误被默默吞掉
    Get-ChildItem -Path "C:\NoSuchFolder" -ErrorAction Ignore
    Write-Host "错误被吞了,我还在继续"
} catch {
    # 这里永远不会触发
    Write-Host "又是永远无法执行的 Catch"
} finally {
    Write-Host "最后还是要做清理"
}

怎么判断是否产生了错误?你可以查看$?变量,它是一个布尔值,表示上一条命令是否成功。如果在Try块里用了Ignore,后续可以用$?来判断。

# 技术栈:PowerShell

try {
    Get-ChildItem -Path "C:\NoSuchFolder" -ErrorAction Ignore
    if ($?) {
        Write-Host "命令成功了"
    } else {
        Write-Host "命令虽然失败,但被忽略了"
    }
} catch {
    # 因为 Ignore 已经阻止了错误升级,所以 Catch 还是不会触发
    Write-Host "仍然不会执行"
}

3.4 陷阱四:Finally 块里又抛出新错误

Finally块按理说是用来做清理的,但如果在清理过程中自身又产生了一个终止型错误,可能会把原来的异常盖过去,甚至导致外层脚本无法正确捕获。这叫做“错误被二次包装”。

# 技术栈:PowerShell

$ErrorActionPreference = "Stop"

try {
    # 故意制造一个终止型错误
    Get-ChildItem -Path "C:\NoSuchFolder" -ErrorAction Stop
} catch {
    Write-Host "第一次捕获:$_"
} finally {
    # 注意这里再抛一个终止错误
    Write-Error "Finally 里出新的幺蛾子了"
}

在这种写法下,Catch块执行完后,Finally里的Write-Error也会产生终止错误,而这个错误不会再去触发当前Try的Catch,而是会直接抛给外部。如果你的脚本没有保护,就可能中断掉。最麻烦的是,你原本想处理的错误反而被新的错误替换了,排查起来特别困惑。

四、怎么才能让它们好好配合

4.1 用局部参数代替全局变量

最简单的办法就是给命令显式加上-ErrorAction Stop,这样不需要改全局变量,也不会影响到其他代码。TryCatch立刻就能生效。

# 技术栈:PowerShell

try {
    Get-ChildItem -Path "C:\NoSuchFolder" -ErrorAction Stop
} catch {
    Write-Host "捕获到了:$_"
} finally {
    Write-Host "收工"
}

4.2 如果非要改全局,务必保存并恢复

有些场景下,你需要让整个脚本内的旧版Cmdlet也遵守Stop规则。这时候可以先把原来的值保存到变量里,在脚本结束前恢复。用try/finally来包装最保险。

# 技术栈:PowerShell

$originalPreference = $ErrorActionPreference
$ErrorActionPreference = "Stop"

try {
    # 这里放你的整套逻辑
    Get-Item "C:\NoSuchFolder"
    Write-Host "如果出错,这一句不会跑"
} catch {
    Write-Host "统一处理:$_"
} finally {
    # 无论怎样都恢复
    $ErrorActionPreference = $originalPreference
    Write-Host "恢复到:$ErrorActionPreference"
}

4.3 自己写函数时避免副作用

如果你在写函数,千万别在函数内部直接给$global:ErrorActionPreference赋值。只在当前作用域修改即可。PowerShell的函数内部默认变量是局部的,所以直接赋值不会影响外部。但要注意,如果函数里调用了其他模块,依然是隐式依赖。最佳实践是在函数开头也保存原值,并在finally里恢复,确保函数退出时环境整洁。

# 技术栈:PowerShell

function Invoke-SafeTask {
    # 保存外部偏好
    $original = $ErrorActionPreference
    $ErrorActionPreference = "Stop"

    try {
        # 内部逻辑
        Get-Item "C:\NoSuchFolder"
    } catch {
        Write-Host "函数内部捕获:$_"
    } finally {
        # 恢复外部偏好
        $ErrorActionPreference = $original
    }
}

# 调用函数
Invoke-SafeTask
# 外部偏好没有被改变
Write-Host "外部偏好仍然是:$ErrorActionPreference"

4.4 正确处理 Finally 里的异常

如果Finally块里可能出错,最好把里面的命令也单独包裹一层try/catch,或者用-ErrorAction SilentlyContinue先压住,避免新错误覆盖旧错误。

# 技术栈:PowerShell

try {
    Get-Item "C:\NoSuchFolder" -ErrorAction Stop
} catch {
    Write-Host "捕获主错误:$_"
} finally {
    # 防止 Finally 里的错误覆盖主错误
    try {
        Remove-Item "C:\temp\cleanup.txt" -ErrorAction Stop
    } catch {
        Write-Host "清理时也出错,但先记录下来:$_"
    }
}

五、应用场景、优缺点与注意事项

5.1 哪些场景最常用到

像文件批量处理、磁盘清理、服务重启这类任务,你希望某一类错误一旦发生就立刻停止并记录,这时候用-ErrorAction Stop配合TryCatch就很顺手。另外,在调用一些默认不抛出终止错误的.NET方法或者老式COM组件时,也需要用ErrorActionPreference=Stop来让错误能被捕获。

5.2 这套机制有什么好处

它的好处是灵活。你可以按命令控制,也可以按全局统一控制。既能细到给一条命令指定忽略错误,也能大到让整个脚本所有的非终止错误都“升级”为终止错误。用好了之后,脚本的健壮性会有明显提升。

5.3 有什么槽点

槽点也很明显:规则太多,容易误用。全局变量、命令参数、错误类型、作用域,每一环都可能改变最终行为。尤其是别人写的模块偷偷改了全局偏好,或者命令参数优先级太高,都会让你的TryCatch形同虚设。另外,错误被忽略后不容易察觉,调试起来很伤脑筋。

5.4 注意事项清单

  • 不要轻易修改全局的$ErrorActionPreference,除非你清楚后果。
  • 每个命令的-ErrorAction参数优先级高于全局变量,别被它“出卖”。
  • 使用SilentlyContinue或Ignore时,一定要意识到Catch可能不会被触发。
  • 在函数或模块中,尽量使用局部赋值,并善用try/finally恢复配置。
  • 遇到非终止型错误又想捕获时,记得给命令加上-ErrorAction Stop。
  • 别忘了$?能告诉你上一条命令到底成功没成功,在错误被忽略时是重要的诊断工具。
  • Finally里的清理代码也要做好错误防护,否则可能盖掉你辛苦捕获到的原错误。

六、总结

PowerShell的错误处理就像管理一个团队:TryCatch是你制定的紧急预案,但只有成员认为事情足够重大时才会启动预案。ErrorActionPreference就是团队的默认情绪——如果大家都觉得“一点小错继续走”,那预案自然派不上用场;如果大家觉得“有问题就该叫停”,预案才能真正生效。理解了这两种错误类型、一个全局变量和一个命令级参数之间的博弈,你就能写出既健壮又不易被骗的PowerShell脚本。别怕踩坑,把这些陷阱记住,你的脚本将来会省心很多。