适用于 PowerShell 5.1 及以上版本
回顾这一年的技能连载,我们从基础命令一路走到了高级自动化场景。在实际生产环境中,能跑通一段脚本只是起点,让脚本在面对异常输入、网络波动、权限变更时依然稳定运行,才是工程师的真正功力。今天我们就来系统梳理 PowerShell 脚本编写的最佳实践。
这些实践并非教条,而是从大量生产故障中提炼出来的经验总结。遵循它们可以让你的脚本更健壮、更易维护,也让接手代码的同事少踩几个坑。无论你是刚入门的新手还是资深运维工程师,这些原则都值得时刻对照。
代码结构与命名规范
良好的命名和结构是可维护脚本的基石。PowerShell 社区有一套广泛接受的动词-名词命名约定,遵循它能让你的函数与内置 cmdlet 保持一致的调用体验。同时,参数验证、类型约束和结构化注释也是专业脚本的标配。
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
|
function Get-FileStatistics { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [ValidateScript({ if (-not (Test-Path -Path $_ -PathType Container)) { throw "指定的路径不存在或不是目录: $_" } $true })] [string]$Path,
[Parameter()] [ValidateRange(1, 100)] [int]$TopN = 10 )
begin { $startTime = Get-Date Write-Verbose "开始扫描目录: $Path" }
process { $files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction SilentlyContinue
$stats = $files | Group-Object Extension | Sort-Object Count -Descending | Select-Object -First $TopN | ForEach-Object { [PSCustomObject]@{ Extension = $_.Name Count = $_.Count TotalSizeMB = [math]::Round( ($_.Group | Measure-Object Length -Sum).Sum / 1MB, 2 ) } }
$stats }
end { $elapsed = (Get-Date) - $startTime Write-Verbose "扫描完成,耗时: $($elapsed.TotalSeconds.ToString('F2')) 秒" } }
Get-FileStatistics -Path "/var/log" -TopN 5 -Verbose
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10
| VERBOSE: 开始扫描目录: /var/log VERBOSE: 扫描完成,耗时: 0.34 秒
Extension Count TotalSizeMB --------- ----- ----------- .log 128 256.45 .gz 42 1024.80 .json 18 12.30 .txt 7 3.15 .err 3 0.85
|
上面的代码展示了几个关键实践:函数名使用-approved动词加名词的形式,参数加了 [CmdletBinding] 和完整的验证属性,并用基于注释的帮助文档让 Get-Help 能直接识别。begin/process/end 块的划分让管道处理逻辑更清晰。
错误处理与防御性编程
脚本在开发环境能跑通很容易,但生产环境充满了意外:磁盘满、网络断、权限不足、文件被占用。防御性编程的核心理念是”永远假设会出错”,然后用结构化的方式处理每一个可能的失败点。
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
| function Copy-LogArchive { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$SourcePath,
[Parameter(Mandatory)] [string]$DestinationPath,
[Parameter()] [int]$RetryCount = 3,
[Parameter()] [int]$RetryDelaySeconds = 5 )
if (-not (Test-Path $SourcePath)) { Write-Error "源路径不存在: $SourcePath" return }
if (-not (Test-Path $DestinationPath)) { if ($PSCmdlet.ShouldProcess($DestinationPath, "创建目标目录")) { try { New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null Write-Verbose "已创建目标目录: $DestinationPath" } catch { Write-Error "无法创建目标目录: $_" return } } }
$files = @(Get-ChildItem -Path $SourcePath -File -Filter "*.log")
if ($files.Count -eq 0) { Write-Warning "源目录中没有 .log 文件,操作跳过" return }
$copiedCount = 0 $failedFiles = @()
foreach ($file in $files) { $destFile = Join-Path $DestinationPath $file.Name $attempt = 0 $success = $false
while ($attempt -lt $RetryCount -and -not $success) { $attempt++ try { if ($PSCmdlet.ShouldProcess($file.FullName, "复制到 $DestinationPath")) { Copy-Item -Path $file.FullName -Destination $destFile -Force $copiedCount++ $success = $true Write-Verbose "[$attempt/$RetryCount] 复制成功: $($file.Name)" } } catch { Write-Warning "[$attempt/$RetryCount] 复制失败: $($file.Name) - $($_.Exception.Message)" if ($attempt -lt $RetryCount) { Write-Verbose "等待 $RetryDelaySeconds 秒后重试..." Start-Sleep -Seconds $RetryDelaySeconds } else { $failedFiles += $file.Name } } } }
$report = [PSCustomObject]@{ TotalFiles = $files.Count CopiedFiles = $copiedCount FailedFiles = $failedFiles.Count FailedList = $failedFiles -join ", " Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" }
if ($failedFiles.Count -gt 0) { Write-Warning "部分文件复制失败: $($failedFiles -join ', ')" }
$report }
Copy-LogArchive -SourcePath "C:\Logs\App" -DestinationPath "\\Server\Backup\Logs" -RetryCount 3 -Verbose
|
执行结果示例:
1 2 3 4 5 6 7 8 9 10 11 12
| VERBOSE: 已创建目标目录: \\Server\Backup\Logs VERBOSE: [1/3] 复制成功: app-2025-12-28.log VERBOSE: [1/3] 复制成功: app-2025-12-29.log WARNING: [1/3] 复制失败: app-2025-12-30.log - 被其他进程占用。 VERBOSE: 等待 5 秒后重试... VERBOSE: [2/3] 复制成功: app-2025-12-30.log
TotalFiles : 3 CopiedFiles : 3 FailedFiles : 0 FailedList : Timestamp : 2025-12-30 10:15:30
|
这个函数体现了多层防御:前置检查确保输入合法,重试机制应对瞬态故障,SupportsShouldProcess 提供 -WhatIf 和 -Confirm 支持,汇总报告让运维人员一眼看清执行结果。特别注意 @(...) 包裹管道赋值,这确保即使没有文件也返回空数组而非 $null,避免后续 Count 属性报错。
性能优化与安全编码
当脚本处理的文件从几十个变成几万个,或者从单机扩展到数百台远程服务器时,性能和安全就成了不可忽视的问题。掌握这些技巧可以避免脚本在生产中”慢到不可用”或”泄露敏感信息”。
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
| function Get-SecureAuditReport { [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$ComputerName,
[Parameter()] [pscredential]$Credential,
[Parameter()] [ValidateRange(1, 50)] [int]$ThrottleLimit = 10 )
$cimParams = @{ ErrorAction = 'Stop' }
if ($Credential) { $cimParams.Credential = $Credential }
$results = $ComputerName | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { $computer = $_ $params = $using:cimParams
try { $os = Get-CimInstance -ClassName Win32_OperatingSystem @params $patches = Get-CimInstance -ClassName Win32_QuickFixEngineering @params | Sort-Object InstalledOn -Descending -ErrorAction SilentlyContinue | Select-Object -First 5
[PSCustomObject]@{ ComputerName = $computer Status = "Online" OSVersion = $os.Caption LastBootTime = $os.LastBootUpTime FreeSpaceGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2) RecentPatches = $patches.HotFixID -join "; " AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss" } } catch { [PSCustomObject]@{ ComputerName = $computer Status = "Error: $($_.Exception.Message)" OSVersion = "N/A" LastBootTime = $null FreeSpaceGB = "N/A" RecentPatches = "N/A" AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss" } } }
$results | Format-Table -AutoSize
$logEntry = @{ Action = "SecurityAudit" Targets = $ComputerName -join "," ResultCount = ($results | Where-Object Status -eq "Online").Count ExecutedBy = $env:USERNAME Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ" }
$logEntry | ConvertTo-Json -Compress }
$cred = Get-Credential -Message "输入远程服务器管理凭据" Get-SecureAuditReport -ComputerName "SRV01", "SRV02", "SRV03" -Credential $cred -ThrottleLimit 5
|
执行结果示例:
1 2 3 4 5 6 7
| ComputerName Status OSVersion LastBootTime FreeSpaceGB RecentPatches AuditTime ------------ ------ --------- ------------ ----------- ------------- --------- SRV01 Online Microsoft Windows Server 2022 2025-12-28 03:00:00 12.45 KB5044280;KB503... 2025-12-30 14:20:10 SRV02 Online Microsoft Windows Server 2019 2025-12-29 06:30:00 8.72 KB5044277;KB503... 2025-12-30 14:20:12 SRV03 Error: 无法连接到远程服务器。 N/A N/A N/A 2025-12-30 14:20:15
{"Action":"SecurityAudit","Targets":"SRV01,SRV02,SRV03","ResultCount":2,"ExecutedBy":"admin","Timestamp":"2025-12-30T14:20:15Z"}
|
这个函数使用了 ForEach-Object -Parallel 实现受控并发,用 CIM 替代已弃用的 WMI 协议,通过 PSCredential 对象安全传递凭据而非明文密码,审计日志中严格排除敏感字段。这些都是生产环境中必须考虑的安全和性能要点。
注意事项
始终使用 approved verbs:用 Get-Verb 查看合法动词列表,避免自定义动词导致命名不一致。如果不确定用什么动词,参考类似功能的内置 cmdlet。
参数验证优于函数内 if 判断:[ValidateNotNullOrEmpty()]、[ValidateRange()]、[ValidateScript()] 等属性在参数绑定时就生效,比函数体内的 if 检查更早报错,错误信息也更标准。
永远不要在脚本中硬编码密码:使用 Get-Credential 交互获取凭据,或从 Azure Key Vault、Windows Credential Manager 等安全存储读取。代码中的 $Credential.Password 是 SecureString,不能直接转为明文。
用 -ErrorAction Stop 配合 try/catch:全局设置 $ErrorActionPreference = 'Stop' 可能导致意想不到的中断,建议在特定命令上用 -ErrorAction 精确控制,然后在 try 块中捕获。
大集合用 @() 包裹管道赋值:$items = @(Get-ChildItem ...) 确保 $items 始终是数组,即使结果为空或只有一个元素。这样后续的 .Count 和 foreach 行为一致,不会踩类型陷阱。
为函数写基于注释的帮助:.SYNOPSIS、.DESCRIPTION、.PARAMETER、.EXAMPLE 四件套是最低要求。三个月后你自己也会感谢今天写了注释的那个函数,更不用说接手代码的同事了。