PowerShell 技能连载 - 流式处理与管道优化

适用于 PowerShell 7.0 及以上版本

管道的代价与机遇

PowerShell 的管道(Pipeline)是其最具标志性的设计之一。不同于传统 Shell 将文本在进程间传递,PowerShell 管道传递的是完整的 .NET 对象,这意味着每条命令的输出可以携带类型信息、方法和属性。然而,这种强大能力的背后隐藏着性能陷阱:当数据量从几百条增长到几十万条时,不当的管道用法可能导致内存飙升、执行时间倍增,甚至脚本完全失去响应。

理解管道的内部工作原理是写出高效脚本的前提。PowerShell 使用延迟枚举(Deferred Enumeration)机制,理论上可以逐条处理数据而不必将其全部加载到内存。但在实际编写中,很多常见写法会无意间打破这种流式特性,将整个数据集一次性收集到内存中。本文将从性能对比、流式处理技巧和高级管道模式三个维度,帮助你掌握管道优化的核心方法。

管道性能对比:数组累加 vs 管道 vs List

下面的脚本用三种方式完成相同的任务——生成 10 万个对象并过滤,然后对比它们的执行时间和内存占用。

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
# 三种方式处理 10 万条数据的性能对比
$count = 100000

# 方式一:数组累加(+=)——每次都创建新数组,性能最差
$result1 = @()
$sw1 = [System.Diagnostics.Stopwatch]::StartNew()
for ($i = 1; $i -le $count; $i++) {
if ($i % 2 -eq 0) {
$result1 += "Item-$i"
}
}
$sw1.Stop()
$time1 = $sw1.Elapsed.TotalMilliseconds
$mem1 = [System.GC]::GetTotalMemory($true)

# 方式二:管道 + Where-Object —— 延迟枚举,内存友好
$sw2 = [System.Diagnostics.Stopwatch]::StartNew()
$result2 = 1..$count | Where-Object { $_ % 2 -eq 0 } | ForEach-Object { "Item-$_" }
$sw2.Stop()
$time2 = $sw2.Elapsed.TotalMilliseconds
$mem2 = [System.GC]::GetTotalMemory($true)

# 方式三:List<T> + foreach —— 最佳性能
$list = [System.Collections.Generic.List[string]]::new()
$sw3 = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($i in 1..$count) {
if ($i % 2 -eq 0) {
$list.Add("Item-$i")
}
}
$sw3.Stop()
$time3 = $sw3.Elapsed.TotalMilliseconds
$mem3 = [System.GC]::GetTotalMemory($true)

# 输出对比结果
[PSCustomObject]@{
'方式' = '数组累加 (+=)'
'耗时(ms)' = [math]::Round($time1, 2)
'结果数量' = $result1.Count
} | Format-Table -AutoSize

[PSCustomObject]@{
'方式' = '管道 Where-Object'
'耗时(ms)' = [math]::Round($time2, 2)
'结果数量' = $result2.Count
} | Format-Table -AutoSize

[PSCustomObject]@{
'方式' = 'List<T> + foreach'
'耗时(ms)' = [math]::Round($time3, 2)
'结果数量' = $list.Count
} | Format-Table -AutoSize

Write-Host "`n结论: List<T> + foreach 最快,管道适中,数组累加最慢。"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
方式            耗时(ms)  结果数量
---- -------- --------
数组累加 (+=) 28456.73 50000

方式 耗时(ms) 结果数量
---- -------- --------
管道 Where-Object 1250.38 50000

方式 耗时(ms) 结果数量
---- -------- --------
List<T> + foreach 186.52 50000

结论: List<T> + foreach 最快,管道适中,数组累加最慢。

从结果可以看出,数组累加的方式由于每次 += 操作都会创建一个全新的数组并拷贝所有已有元素,时间复杂度呈 O(n^2) 增长,在数据量较大时性能急剧恶化。管道方式虽然引入了命令调用的开销,但利用了延迟枚举机制,内存占用可控。而 List<T> 配合 foreach 循环则是性能最优的选择,适合对性能要求极高的场景。

流式处理技巧:逐行处理大文件

在处理大型日志文件、CSV 导出或海量数据集时,流式处理(Streaming)能够显著降低内存峰值。核心思路是让数据在管道中逐条流动,而不是先将所有数据收集到一个大数组中再统一处理。

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
# 流式处理大文件:逐行读取、过滤、输出,内存占用恒定
$logfile = "/var/log/system.log"

# 错误写法:一次性读取全部内容,文件很大时内存暴涨
# $allLines = Get-Content $logfile # 全部加载到内存
# $errors = $allLines | Where-Object { $_ -match 'ERROR' }

# 正确写法:使用 -ReadCount 0 让管道逐行流式处理
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$errorCount = 0
$uniqueModules = [System.Collections.Generic.HashSet[string]]::new()

Get-Content $logfile -ReadCount 0 |
Where-Object { $_ -match '\[ERROR\]' } |
ForEach-Object {
$errorCount++
if ($_ -match '\[(\w+)\]') {
$null = $uniqueModules.Add($Matches[1])
}
# 只输出前 5 条作为预览
if ($errorCount -le 5) {
$_
}
}

$sw.Stop()
Write-Host "`n--- 统计结果 ---"
Write-Host "扫描耗时: $($sw.Elapsed.TotalSeconds.ToString('F2')) 秒"
Write-Host "错误总数: $errorCount"
Write-Host "涉及模块: $($uniqueModules.Count) 个"
Write-Host "模块列表: $($uniqueModules -join ', ')"

# 流式处理 CSV 文件的技巧:使用 Import-Csv 配合管道
Write-Host "`n--- 流式 CSV 处理示例 ---"
$csvData = @"
Name,Department,Salary
Alice,Engineering,15000
Bob,Marketing,12000
Charlie,Engineering,18000
Diana,Marketing,11000
Eve,Engineering,20000
"@ | ConvertFrom-Csv

# 管道中间过滤,避免中间集合
$highEarners = $csvData |
Where-Object { [int]$_.Salary -gt 13000 } |
Group-Object Department |
ForEach-Object {
[PSCustomObject]@{
Department = $_.Name
Count = $_.Count
AvgSalary = [math]::Round(($_.Group | Measure-Object -Property Salary -Average).Average, 0)
}
}

$highEarners | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2026-02-12 03:14:22 [ERROR] [Network] Connection timeout to 10.0.1.50
2026-02-12 03:15:01 [ERROR] [Auth] Failed login attempt from 192.168.1.100
2026-02-12 03:16:44 [ERROR] [Storage] Disk write failed on /dev/sda2
2026-02-12 03:17:12 [ERROR] [Network] DNS resolution failed for api.example.com
2026-02-12 03:18:55 [ERROR] [Auth] Token expired for user admin

--- 统计结果 ---
扫描耗时: 0.34 秒
错误总数: 47
涉及模块: 3 个
模块列表: Network, Auth, Storage

--- 流式 CSV 处理示例 ---
Department Count AvgSalary
----------- ----- ---------
Engineering 3 17667

关键要点在于使用 -ReadCount 0 参数让 Get-Content 逐行输出而非一次性返回数组。此外,在管道中间使用 Where-Object 进行过滤时,符合条件的对象会立即传递给下一个命令,不需要等待所有数据都处理完毕。这种”即来即走”的模式就是流式处理的精髓。

高级管道模式:自定义管道函数

PowerShell 函数通过 beginprocessend 三个代码块实现管道感知。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
95
96
97
# 自定义管道函数:统计文本行信息的流式处理器
function Measure-TextStatistics {
<#
.SYNOPSIS
流式统计文本的字符数、单词数和行数
.PARAMETER InputObject
通过管道传入的文本行
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$InputObject
)

begin {
$totalChars = 0
$totalWords = 0
$lineCount = 0
$longestLine = 0
Write-Verbose "开始统计..."
}

process {
$lineCount++
$totalChars += $InputObject.Length
$wordCount = ($InputObject -split '\s+').Where({ $_.Length -gt 0 }).Count
$totalWords += $wordCount
if ($InputObject.Length -gt $longestLine) {
$longestLine = $InputObject.Length
}
# 流式输出每行的即时统计
[PSCustomObject]@{
Line = $lineCount
Chars = $InputObject.Length
Words = $wordCount
IsLong = $InputObject.Length -gt 80
}
}

end {
Write-Verbose "统计完成"
# 最终汇总对象
[PSCustomObject]@{
TotalLines = $lineCount
TotalChars = $totalChars
TotalWords = $totalWords
LongestLine = $longestLine
AvgWordsLine = if ($lineCount -gt 0) { [math]::Round($totalWords / $lineCount, 1) } else { 0 }
}
}
}

# 使用自定义管道函数处理数据
Write-Host "=== 逐行输出 ==="
$results = @(
"The quick brown fox jumps over the lazy dog"
"PowerShell pipeline processing is both powerful and memory efficient"
"Short line"
"This is a particularly long line that exceeds eighty characters to demonstrate the IsLong flag behavior"
"End of sample data"
) | Measure-TextStatistics -Verbose

$results | Format-Table -AutoSize

Write-Host "`n=== 管道绑定参数示例 ==="
# 演示 ValueFromPipelineByPropertyName 属性绑定
function Get-ProcessedReport {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$Name,

[Parameter(ValueFromPipelineByPropertyName)]
[int]$Salary,

[int]$BonusPercent = 10
)

process {
$bonus = [math]::Round($Salary * $BonusPercent / 100, 2)
[PSCustomObject]@{
Employee = $Name
BaseSalary = $Salary
Bonus = $bonus
Total = $Salary + $bonus
}
}
}

# 对象属性的 Name 和 Salary 会自动绑定到函数参数
$employees = @(
[PSCustomObject]@{ Name = "Alice"; Salary = 15000; Department = "Eng" }
[PSCustomObject]@{ Name = "Bob"; Salary = 12000; Department = "Mkt" }
[PSCustomObject]@{ Name = "Charlie"; Salary = 18000; Department = "Eng" }
)

$employees | Get-ProcessedReport -BonusPercent 15 | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
VERBOSE: 开始统计...
VERBOSE: 统计完成
=== 逐行输出 ===
Line Chars Words IsLong
---- ----- ----- ------
1 44 9 False
2 61 10 False
3 10 2 False
4 89 15 True
5 19 4 False

TotalLines TotalChars TotalWords LongestLine AvgWordsLine
---------- ----------- ---------- ----------- ------------
5 223 40 89 8.0

=== 管道绑定参数示例 ===
Employee BaseSalary Bonus Total
-------- ---------- ----- -----
Alice 15000 2250.00 17250.00
Bob 12000 1800.00 13800.00
Charlie 18000 2700.00 20700.00

begin/process/end 三段式结构让函数天然适配管道场景。begin 块中初始化的变量在整个管道生命周期内保持状态,process 块为每条数据执行一次,end 块在收尾时输出汇总。第二个示例展示了 ValueFromPipelineByPropertyName 参数绑定的威力——当管道对象的属性名与函数参数名一致时,PowerShell 会自动完成映射,无需手动提取属性再传参。这种设计让函数之间的组合变得自然流畅。

注意事项

  1. 避免在循环中使用 += 累加数组。每次 += 都会创建新数组并复制所有已有元素,数据量大时性能呈指数级恶化。应改用 List<T>Add 方法或直接使用管道收集结果。

  2. 大文件处理务必使用流式读取Get-Content 默认会一次性读取全部内容,加上 -ReadCount 0 参数后改为逐行输出。对于超大文件(GB 级别),还可以考虑使用 [System.IO.StreamReader] 进行更底层的流式读取。

  3. 理解管道中的”阻塞”操作Sort-ObjectGroup-ObjectMeasure-Object 等命令必须等待所有输入才能产出结果,它们会打破流式特性。在不需要全局排序或分组的场景中,应尽量将这类命令放在管道末端或避免使用。

  4. 自定义管道函数必须包含 process。如果函数只有 beginend 块而没有 process 块,管道传入的对象会被忽略。这是初学者编写管道函数时最常见的错误之一。

  5. 注意管道中的类型转换开销。当管道中的对象需要在不同类型间转换时(例如字符串转数字),会在每次处理时产生额外的解析成本。对于高频操作,建议在进入管道前统一完成类型转换。

  6. 权衡可读性与性能。管道写法天然具有良好的可读性和可组合性,在大多数场景下其性能已经足够。不要为了微小的性能提升而牺牲代码的可维护性——只有在经过实际测量确认存在瓶颈时,才需要切换到 List<T> + foreach 等更底层的方式。

PowerShell 技能连载 - 流式输出与进度显示

适用于 PowerShell 5.1 及以上版本

背景

在执行长时间运行的 PowerShell 脚本时,用户最怕的不是等太久,而是不知道还要等多久。默认情况下,foreach 循环处理数百个对象时,控制台安静得仿佛脚本已经卡死。这种”黑盒体验”不仅让运维人员焦虑,也使得自动化流水线难以判断任务的实时进度。

PowerShell 提供了 Write-Progress cmdlet 来在控制台顶部显示进度条,配合 $ProgressPreference 变量可以精细控制进度显示的行为。与此同时,PowerShell 的管道流式输出机制允许你在处理数据的过程中逐条输出结果,而不必等到整个集合处理完毕。流式输出与进度显示相结合,就能构建出既高效又友好的脚本体验。

本文将从 Write-Progress 的基本用法出发,介绍 $ProgressPreference 的各种取值及效果,再通过实战案例演示如何在批量处理场景中同时实现流式输出和实时进度反馈。

基础:使用 Write-Progress 显示进度条

Write-Progress 是 PowerShell 内置的进度显示 cmdlet,它接受 -Activity(主标题)、-Status(当前状态文本)、-PercentComplete(百分比)和 -CurrentOperation(当前操作详情)等参数。下面通过一个模拟批量处理文件的场景来展示其基本用法。

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
# 模拟批量处理文件并显示进度条
$fileList = @(
"report-q1.xlsx"
"report-q2.xlsx"
"report-q3.xlsx"
"report-q4.xlsx"
"summary.docx"
"budget-2025.xlsx"
"forecast.csv"
"inventory.json"
"audit-trail.log"
"config-backup.xml"
)

$total = $fileList.Count
$index = 0

foreach ($file in $fileList) {
$index++
$percent = [math]::Floor(($index / $total) * 100)

Write-Progress -Activity "批量处理文件" `
-Status "正在处理 ($index / $total): $file" `
-PercentComplete $percent `
-CurrentOperation "校验文件完整性"

# 模拟处理耗时
Start-Sleep -Milliseconds 300

# 流式输出处理结果
$result = [PSCustomObject]@{
File = $file
Status = "已完成"
Size = "$(Get-Random -Minimum 10 -Maximum 9999) KB"
Timestamp = Get-Date -Format "HH:mm:ss"
}
Write-Output $result
}

# 关闭进度条
Write-Progress -Activity "批量处理文件" -Completed

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
File              Status  Size     Timestamp
---- ------ ---- ---------
report-q1.xlsx 已完成 2341 KB 09:15:32
report-q2.xlsx 已完成 1876 KB 09:15:32
report-q3.xlsx 已完成 3210 KB 09:15:33
report-q4.xlsx 已完成 987 KB 09:15:33
summary.docx 已完成 5432 KB 09:15:34
budget-2025.xlsx 已完成 1567 KB 09:15:34
forecast.csv 已完成 8234 KB 09:15:34
inventory.json 已完成 456 KB 09:15:35
audit-trail.log 已完成 7891 KB 09:15:35
config-backup.xml 已完成 123 KB 09:15:35

这段代码展示了 Write-Progress 的核心用法。-Activity 定义进度条的主标题,-Status 显示当前正在处理哪一项,-PercentComplete 控制进度条的填充比例。注意最后的 Write-Progress -Completed 调用——它会清除控制台顶部的进度条,否则进度条会一直停留在屏幕上。每个文件处理完后立即通过 Write-Output 输出结果对象,实现了”边处理边输出”的流式效果。

进阶:使用 $ProgressPreference 控制进度行为

$ProgressPreference 是一个全局偏好变量,决定了 PowerShell 如何处理 Write-Progress 调用。它有四个有效取值:Continue(默认,显示进度条)、SilentlyContinue(不显示但继续执行)、Ignore(完全忽略进度输出)和 Inquire(每次显示进度前暂停询问用户)。不同的取值在不同场景下各有用处。

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
# 展示 $ProgressPreference 四种取值的效果
$names = @("Alpha", "Beta", "Gamma", "Delta", "Epsilon")

function Invoke-HeavyTask {
param([string[]]$Items, [string]$Mode)

$prevPref = $ProgressPreference
$ProgressPreference = $Mode

$total = $Items.Count
$index = 0

foreach ($item in $Items) {
$index++
$percent = [math]::Floor(($index / $total) * 100)

Write-Progress -Activity "模式: $Mode" `
-Status "处理项: $item" `
-PercentComplete $percent

Start-Sleep -Milliseconds 200
Write-Output " [$Mode] 完成处理: $item"
}

Write-Progress -Activity "模式: $Mode" -Completed
$ProgressPreference = $prevPref
}

# 模式 1: Continue(默认行为,显示进度条)
Write-Output "=== Continue 模式(显示进度条)==="
Invoke-HeavyTask -Items $names -Mode "Continue"

# 模式 2: SilentlyContinue(静默执行,不显示进度条)
Write-Output ""
Write-Output "=== SilentlyContinue 模式(隐藏进度条)==="
Invoke-HeavyTask -Items $names -Mode "SilentlyContinue"

# 模式 3: 在 CI/CD 中临时关闭进度条以提升性能
Write-Output ""
Write-Output "=== 模拟 CI/CD 环境:批量安装模块 ===="
$modules = @("Pester", "PSScriptAnalyzer", "platyPS", "InvokeBuild")
$ProgressPreference = "SilentlyContinue"

foreach ($mod in $modules) {
Write-Output " 正在安装模块: $mod ... (模拟)"
Start-Sleep -Milliseconds 150
Write-Output " 完成: $mod"
}

$ProgressPreference = "Continue"

Write-Output ""
Write-Output "所有模式演示完毕"

执行结果示例:

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
=== Continue 模式(显示进度条)===
[Continue] 完成处理: Alpha
[Continue] 完成处理: Beta
[Continue] 完成处理: Gamma
[Continue] 完成处理: Delta
[Continue] 完成处理: Epsilon

=== SilentlyContinue 模式(隐藏进度条)===
[SilentlyContinue] 完成处理: Alpha
[SilentlyContinue] 完成处理: Beta
[SilentlyContinue] 完成处理: Gamma
[SilentlyContinue] 完成处理: Delta
[SilentlyContinue] 完成处理: Epsilon

=== 模拟 CI/CD 环境:批量安装模块 ====
正在安装模块: Pester ... (模拟)
完成: Pester
正在安装模块: PSScriptAnalyzer ... (模拟)
完成: PSScriptAnalyzer
正在安装模块: platyPS ... (模拟)
完成: platyPS
正在安装模块: InvokeBuild ... (模拟)
完成: InvokeBuild

所有模式演示完毕

这段代码的关键点在于函数内部先保存 $ProgressPreference 的原始值,执行完毕后再恢复。这种”保存-修改-恢复”模式可以避免偏好变量被永久修改后影响后续代码。SilentlyContinue 模式在 CI/CD 自动化场景中特别重要——Write-Progress 的控制台渲染本身有性能开销,在处理数千个对象时可能使脚本总耗时增加 20% 以上。关闭进度条可以显著提升批量操作的执行速度。

实战:带子进度和 ETA 的多层进度显示

当任务包含嵌套结构时(例如处理多个服务器,每台服务器上又有多个检查项),单层进度条无法清晰表达层次关系。Write-Progress 支持 -ParentId 参数来创建嵌套进度条,同时我们可以手动计算预估剩余时间(ETA),让进度信息更加完整。下面模拟一个多服务器安全巡检的场景。

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
# 多层嵌套进度条 + ETA 预估
$servers = @(
@{ Name = "WEB-01"; IP = "192.168.1.10" }
@{ Name = "WEB-02"; IP = "192.168.1.11" }
@{ Name = "DB-01"; IP = "192.168.1.20" }
@{ Name = "DB-02"; IP = "192.168.1.21" }
@{ Name = "CACHE-01"; IP = "192.168.1.30" }
)

$checks = @("端口扫描", "服务状态", "磁盘空间", "内存使用", "安全补丁")

$startTime = Get-Date
$serverIndex = 0
$totalServers = $servers.Count
$allResults = @()

foreach ($server in $servers) {
$serverIndex++
$serverPercent = [math]::Floor(($serverIndex / $totalServers) * 100)
$elapsed = (Get-Date) - $startTime
$avgPerServer = $elapsed.TotalSeconds / $serverIndex
$remaining = [math]::Ceiling($avgPerServer * ($totalServers - $serverIndex))
$eta = (Get-Date).AddSeconds($remaining).ToString("HH:mm:ss")

$statusText = "服务器 $serverIndex/$totalServers - 预计剩余 ${remaining}s (ETA: $eta)"

# 父进度条
Write-Progress -Id 1 -Activity "服务器安全巡检" `
-Status $statusText `
-PercentComplete $serverPercent

$checkIndex = 0
$totalChecks = $checks.Count
$serverHealthy = $true

foreach ($check in $checks) {
$checkIndex++
$checkPercent = [math]::Floor(($checkIndex / $totalChecks) * 100)

# 子进度条(ParentId = 1)
Write-Progress -Id 2 -ParentId 1 -Activity "检查: $check" `
-Status "$($server.Name) ($($server.IP)) - 步骤 $checkIndex/$totalChecks" `
-PercentComplete $checkPercent

# 模拟检查结果
$isPass = (Get-Random -Maximum 10) -gt 2
if (-not $isPass) {
$serverHealthy = $false
}

$result = [PSCustomObject]@{
Server = $server.Name
IP = $server.IP
Check = $check
Result = if ($isPass) { "通过" } else { "告警" }
Detail = if ($isPass) { "正常" } else { "需要关注" }
CheckTime = Get-Date -Format "HH:mm:ss"
}
$allResults += $result

Start-Sleep -Milliseconds 150
}

# 关闭子进度条
Write-Progress -Id 2 -ParentId 1 -Activity "检查完毕" -Completed
}

# 关闭父进度条
Write-Progress -Id 1 -Activity "服务器安全巡检" -Completed

# 输出汇总报告
Write-Output "==========================================="
Write-Output " 服务器安全巡检报告"
Write-Output " 扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "==========================================="
Write-Output ""

foreach ($result in $allResults) {
$mark = if ($result.Result -eq "通过") { "[OK]" } else { "[!!]" }
Write-Output " $mark $($result.Server) | $($result.Check) | $($result.Detail)"
}

Write-Output ""
$passCount = @($allResults | Where-Object { $_.Result -eq "通过" }).Count
$warnCount = @($allResults | Where-Object { $_.Result -eq "告警" }).Count
Write-Output "-------------------------------------------"
Write-Output " 总计: $($allResults.Count) 项检查"
Write-Output " 通过: $passCount 项"
Write-Output " 告警: $warnCount 项"
Write-Output " 通过率: $([math]::Floor($passCount / $allResults.Count * 100))%"
Write-Output "-------------------------------------------"

执行结果示例:

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
===========================================
服务器安全巡检报告
扫描时间: 2025-12-02 09:30:45
===========================================

[OK] WEB-01 | 端口扫描 | 正常
[OK] WEB-01 | 服务状态 | 正常
[OK] WEB-01 | 磁盘空间 | 正常
[OK] WEB-01 | 内存使用 | 正常
[OK] WEB-01 | 安全补丁 | 正常
[OK] WEB-02 | 端口扫描 | 正常
[!!] WEB-02 | 服务状态 | 需要关注
[OK] WEB-02 | 磁盘空间 | 正常
[OK] WEB-02 | 内存使用 | 正常
[OK] WEB-02 | 安全补丁 | 正常
[OK] DB-01 | 端口扫描 | 正常
[OK] DB-01 | 服务状态 | 正常
[!!] DB-01 | 磁盘空间 | 需要关注
[OK] DB-01 | 内存使用 | 正常
[!!] DB-01 | 安全补丁 | 需要关注
[OK] DB-02 | 端口扫描 | 正常
[OK] DB-02 | 服务状态 | 正常
[OK] DB-02 | 磁盘空间 | 正常
[OK] DB-02 | 内存使用 | 正常
[OK] DB-02 | 安全补丁 | 正常
[OK] CACHE-01 | 端口扫描 | 正常
[!!] CACHE-01 | 服务状态 | 需要关注
[OK] CACHE-01 | 磁盘空间 | 正常
[OK] CACHE-01 | 内存使用 | 正常
[OK] CACHE-01 | 安全补丁 | 正常

-------------------------------------------
总计: 25 项检查
通过: 21 项
告警: 4 项
通过率: 84%
-------------------------------------------

这段代码使用了 Write-Progress-Id-ParentId 参数来创建两层嵌套进度条。外层进度条(Id=1)显示服务器级别的整体进度和 ETA 预估,内层进度条(Id=2,ParentId=1)显示当前服务器上各项检查的进度。ETA 的计算基于已用时间和已完成数量的平均值:$avgPerServer = $elapsed.TotalSeconds / $serverIndex,再乘以剩余数量得到预估秒数。注意每个服务器处理完毕后必须用 -Completed 关闭子进度条,否则子进度条会叠加显示。

注意事项

  1. Write-Progress 在重定向输出时会被忽略。当脚本输出被重定向到文件(如 script.ps1 > output.txt)或在后台作业(Start-Job)中执行时,Write-Progress 调用不会产生任何可见效果,但调用本身仍然存在性能开销。在这些场景下,应主动将 $ProgressPreference 设为 SilentlyContinue 以避免无谓的进度条渲染。

  2. 进度条会显著降低大量迭代的性能Write-Progress 每次调用都会触发控制台 UI 更新,当循环次数达到数千甚至数万时,进度条本身的开销可能超过实际业务逻辑。建议在循环中加入计数器,每处理 N 条记录才更新一次进度条(例如每 100 条更新一次),而不是每条记录都调用 Write-Progress

  3. -ParentId 嵌套层级不宜过深。PowerShell 控制台最多支持两层进度条(父和子),PowerShell 7 的终端理论上支持更多层,但超过三层后 UI 会变得混乱且难以阅读。如果任务确实有多层结构,建议只在最外层和当前处理层显示进度,中间层级通过 -Status 文本信息来表达。

  4. $ProgressPreference 的作用域遵循 PowerShell 作用域规则。在函数内修改 $ProgressPreference 默认只影响当前函数作用域,不会传播到调用者。但如果在脚本顶层修改,则会影响该脚本内所有后续代码。最佳实践是在函数内采用”保存-修改-恢复”模式,在脚本开头统一设置则用 try/finally 确保异常时也能恢复原始值。

  5. Write-Progress -SecondsRemaining 参数可以替代手动 ETA 计算。除了手动计算预估时间外,Write-Progress 自带 -SecondsRemaining 参数,PowerShell 会将其显示在进度条右侧。但这个参数只是你传入的一个数值,PowerShell 不会自动计算——你仍然需要自己根据已用时间和已完成比例来推算剩余秒数。

  6. 在 VS Code 集成终端中进度条显示可能异常。VS Code 的 PowerShell 集成终端对 VT100 转义序列的支持有限,Write-Progress 可能表现为闪烁或不完整的进度条。在开发调试阶段,建议直接在 Windows Terminal 或 PowerShell ISE 中运行脚本以获得最佳进度条显示效果,或者将 $ProgressPreference 设为 SilentlyContinue 改用 Write-Host 输出简洁的文本进度信息。

PowerShell 技能连载 - 管道高级技巧

适用于 PowerShell 5.1 及以上版本

管道(Pipeline)是 PowerShell 最核心的设计理念——不同于 Unix Shell 的文本管道,PowerShell 传递的是完整的 .NET 对象。这意味着管道中的每个命令都可以访问对象的属性和方法,无需正则表达式解析。然而,很多用户只停留在 | Format-Table 的层面,不了解管道的流式处理特性、自定义管道函数、管道变量等高级功能。

本文将深入讲解 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
29
30
31
32
33
34
35
36
37
38
39
40
41
# 理解管道参数绑定
# ByPropertyName:对象属性名匹配参数名
# ByValue:对象类型匹配参数类型

# ByPropertyName 示例——属性名匹配
$csvData = @"
Name,Id,Status
SRV01,101,Running
SRV02,102,Stopped
SRV03,103,Running
"@ | ConvertFrom-Csv

# CSV 对象的 Name 属性自动绑定到 -Name 参数
$csvData | ForEach-Object {
Write-Host "服务器 $($_.Name) (ID: $($_.Id)) 状态:$($_.Status)"
}

# 自定义管道绑定函数
function Set-ServerStatus {
[CmdletBinding()]
param(
# ValueFromPipeline 表示从管道接收输入
[Parameter(Mandatory, ValueFromPipeline)]
[string]$ServerName,

# ValueFromPipelineByPropertyName 表示从对象属性匹配
[Parameter(ValueFromPipelineByPropertyName)]
[string]$Status = "Unknown"
)

process {
$icon = if ($Status -eq "Running") { "[OK]" } else { "[!!]" }
Write-Host "$icon $ServerName - $Status" -ForegroundColor $(if ($Status -eq "Running") { "Green" } else { "Yellow" })
}
}

# 从管道接收
$csvData | Set-ServerStatus

# 直接传参
Set-ServerStatus -ServerName "SRV04" -Status "Running"

执行结果示例:

1
2
3
4
5
6
7
服务器 SRV01 (ID: 101) 状态:Running
服务器 SRV02 (ID: 102) 状态:Stopped
服务器 SRV03 (ID: 103) 状态:Running
[OK] SRV01 - Running
[!!] SRV02 - Stopped
[OK] SRV03 - Running
[OK] SRV04 - Running

process 块与流式处理

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 Measure-ServerHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$ComputerName
)

begin {
$total = 0
$healthy = 0
Write-Host "开始健康检查..." -ForegroundColor Cyan
}

process {
$total++
$isOnline = Test-Connection -ComputerName $ComputerName -Count 1 -Quiet -ErrorAction SilentlyContinue

if ($isOnline) {
$healthy++
Write-Host " $ComputerName : 在线" -ForegroundColor Green
} else {
Write-Host " $ComputerName : 离线" -ForegroundColor Red
}

# 每处理一个对象就输出,实现流式处理
[PSCustomObject]@{
Computer = $ComputerName
Online = $isOnline
}
}

end {
$rate = if ($total -gt 0) { [math]::Round($healthy / $total * 100, 1) } else { 0 }
Write-Host "检查完成:$healthy/$total 在线($rate%)" -ForegroundColor Cyan
}
}

# 管道流式处理——逐个处理,不等待全部完成
@("SRV01", "SRV02", "SRV03", "SRV04", "SRV05") | Measure-ServerHealth

# 管道可以继续连接
@("SRV01", "SRV02") | Measure-ServerHealth | Where-Object { $_.Online } | Select-Object -ExpandProperty Computer

执行结果示例:

1
2
3
4
5
6
7
开始健康检查...
SRV01 : 在线
SRV02 : 在线
SRV03 : 离线
SRV04 : 在线
SRV05 : 在线
检查完成:4/5 在线(80.0%)

管道性能优化

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
# 对比:管道 vs 循环的性能差异
$numbers = 1..10000

# 方式 1:管道(较慢,但内存友好)
$sw1 = [System.Diagnostics.Stopwatch]::StartNew()
$result1 = $numbers | ForEach-Object { $_ * 2 }
$sw1.Stop()
Write-Host "管道方式:$($sw1.ElapsedMilliseconds)ms,结果数:$($result1.Count)"

# 方式 2:ForEach-Object -Parallel(PowerShell 7,并行加速)
$sw2 = [System.Diagnostics.Stopwatch]::StartNew()
$result2 = $numbers | ForEach-Object -Parallel { $_ * 2 } -ThrottleLimit 8
$sw2.Stop()
Write-Host "并行方式:$($sw2.ElapsedMilliseconds)ms,结果数:$($result2.Count)"

# 方式 3:赋值语法(最快)
$sw3 = [System.Diagnostics.Stopwatch]::StartNew()
$result3 = foreach ($n in $numbers) { $n * 2 }
$sw3.Stop()
Write-Host "赋值语法:$($sw3.ElapsedMilliseconds)ms,结果数:$($result3.Count)"

# 方式 4:LINQ(超大数据集)
Add-Type -AssemblyName System.Linq
$sw4 = [System.Diagnostics.Stopwatch]::StartNew()
$result4 = [System.Linq.Enumerable]::Select(
[int[]]$numbers, [Func[int, int]]{ param($x); $x * 2 }
)
$sw4.Stop()
Write-Host "LINQ 方式:$($sw4.ElapsedMilliseconds)ms,结果数:$($result4.Count)"

# 管道优化技巧:避免在管道中调用昂贵操作
# 不好的做法
$sw = [System.Diagnostics.Stopwatch]::StartNew()
1..100 | ForEach-Object {
# 每次循环都创建新连接
$result = Invoke-RestMethod "https://httpbin.org/get" -ErrorAction SilentlyContinue
}
$sw.Stop()

执行结果示例:

1
2
3
4
管道方式:85ms,结果数:10000
并行方式:42ms,结果数:10000
赋值语法:12ms,结果数:10000
LINQ 方式:3ms,结果数:10000

自定义管道命令组合

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-LogEntry {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,

[int]$Tail = 100
)

process {
Get-Content $Path -Tail $Tail -ErrorAction Stop | ForEach-Object {
$parts = $_ -split '\s+', 4
if ($parts.Count -ge 4) {
[PSCustomObject]@{
Timestamp = $parts[0] + ' ' + $parts[1]
Level = $parts[2].Trim('[]')
Message = $parts[3]
}
}
}
}
}

function Where-LogLevel {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
$LogEntry,

[ValidateSet("ERROR", "WARN", "INFO", "DEBUG")]
[string[]]$Level
)

process {
if ($LogEntry.Level -in $Level) {
$LogEntry
}
}
}

function Select-RecentErrors {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
$LogEntry,

[int]$Minutes = 60
)

begin { $cutoff = (Get-Date).AddMinutes(-$Minutes) }

process {
$timestamp = [datetime]::Parse($LogEntry.Timestamp)
if ($timestamp -ge $cutoff) {
$LogEntry
}
}
}

# 管道组合
Get-LogEntry -Path "C:\Logs\app.log" -Tail 500 |
Where-LogLevel -Level "ERROR", "WARN" |
Select-RecentErrors -Minutes 30 |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
Timestamp            Level Message
--------- ----- -------
2025-07-08 08:15:30 ERROR Database connection timeout after 30s
2025-07-08 08:20:45 WARN Memory usage above 80% threshold
2025-07-08 08:25:10 ERROR Failed to process message from queue

注意事项

  1. 内存 vs 速度:管道流式处理内存占用低但速度慢,数组赋值速度快但需要全部加载到内存
  2. PipelineVariable:PowerShell 5.0+ 支持 -PipelineVariable 保存管道中间结果供后续使用
  3. OutVariable:使用 -OutVariable 参数在管道传递的同时收集输出到变量
  4. 避免嵌套管道:在 ForEach-Object 中使用管道会导致性能急剧下降
  5. $_ vs $PSItem:两者等价,$_$PSItem 的别名,表示当前管道对象
  6. 并行管道ForEach-Object -Parallel(PowerShell 7+)使用新的运行空间,变量需要用 $using: 传递