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 技能连载 - Azure DevOps 自动化

适用于 PowerShell 7.0 及以上版本

在现代软件交付体系中,Azure DevOps 已经成为许多团队的核心协作平台。它集成了代码仓库、CI/CD 流水线、工作项追踪和制品管理等功能,为端到端的 DevOps 实践提供了完整支撑。然而随着团队规模扩大和项目数量增多,仅依靠 Web 界面进行日常管理变得低效——批量创建项目、统一配置流水线、定期生成进度报告等场景迫切需要自动化手段。

PowerShell 凭借对 REST API 的原生支持和强大的对象处理能力,是自动化管理 Azure DevOps 的理想工具。通过脚本调用 Azure DevOps REST API,我们可以将重复性的管理操作编排成可重复执行的工作流,例如一键同步多个项目的仓库配置、自动触发全量回归测试流水线、定时生成冲刺健康度报告。

本文将从实际运维场景出发,介绍如何使用 PowerShell 实现 Azure DevOps 的三大类自动化操作:项目与仓库的批量管理、CI/CD 流水线的触发与监控、工作项的查询与报告生成。每个场景都提供可直接运行的完整脚本和执行结果演示。

Azure DevOps API 连接与项目管理

所有 Azure DevOps 自动化的基础是与 REST API 建立可靠的连接。以下代码封装了一个通用的 API 调用函数,并在此基础上实现项目的批量查询和仓库操作。我们将 Personal Access Token(PAT)存储在环境变量中,通过 Base64 编码构造认证头,确保凭据不会以明文形式出现在脚本中。

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
function Invoke-AzDoRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Organization,

[string]$Project,

[Parameter(Mandatory)]
[string]$ApiPath,

[Parameter(Mandatory)]
[securestring]$PatToken,

[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
[string]$Method = 'GET',

[object]$Body,

[string]$ApiVersion = '7.1'
)

# 将 PAT 转换为 Basic Auth 格式
$plainPat = (New-Object PSCredential('user', $PatToken)).GetNetworkCredential().Password
$base64Auth = [Convert]::ToBase64String(
[System.Text.Encoding]::ASCII.GetBytes(":$plainPat")
)

$headers = @{
Authorization = "Basic $base64Auth"
'Content-Type' = 'application/json'
}

# 构建请求 URI
$uri = if ($Project) {
"https://dev.azure.com/$Organization/$Project/_apis$ApiPath"
} else {
"https://dev.azure.com/$Organization/_apis$ApiPath"
}

# 追加 api-version 参数
$separator = if ($uri -match '\?') { '&' } else { '?' }
$uri = "$uri$separator`api-version=$ApiVersion"

$params = @{
Uri = $uri
Headers = $headers
Method = $Method
}

if ($Body) {
$params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
}

try {
Invoke-RestMethod @params
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
$errorReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
$errorBody = $errorReader.ReadToEnd()
Write-Error "API 请求失败 [HTTP $statusCode]: $errorBody"
}
}

# 从环境变量获取 PAT
$pat = ConvertTo-SecureString $env:AZDO_PAT -AsPlainText -Force

# 查询组织下所有项目
$allProjects = Invoke-AzDoRequest `
-Organization 'mycompany' `
-ApiPath '/projects' `
-PatToken $pat

Write-Host "组织内的项目列表:"
Write-Host ("-" * 60)

foreach ($proj in $allProjects.value) {
$lastUpdate = [datetime]::Parse($proj.lastUpdateTime).ToString('yyyy-MM-dd')
Write-Host " 名称: $($proj.name)"
Write-Host " 状态: $($proj.state) | 上次更新: $lastUpdate"
Write-Host " ID: $($proj.id)"
Write-Host ""
}

# 查询指定项目的所有仓库
$repos = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/git/repositories' `
-PatToken $pat

Write-Host "PlatformService 项目的仓库:"
foreach ($repo in $repos.value) {
$branch = $repo.defaultBranch -replace 'refs/heads/', ''
Write-Host " [$($repo.name)] 默认分支: $branch, 大小: $([math]::Round($repo.size / 1MB, 1)) MB"
}

上述代码定义了 Invoke-AzDoRequest 函数,支持组织级和项目级的 API 调用。函数自动根据 URI 是否已包含查询参数来拼接 api-version,避免重复的 ? 符号。错误处理部分捕获 HTTP 异常并解析响应体,便于排查 API 调用失败的原因。通过该函数可以方便地查询项目列表和仓库信息,为后续的批量管理操作奠定基础。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
组织内的项目列表:
------------------------------------------------------------
名称: PlatformService
状态: wellFormed | 上次更新: 2026-01-28
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

名称: DataPipeline
状态: wellFormed | 上次更新: 2026-01-15
ID: b2c3d4e5-f6a7-8901-bcde-f12345678901

名称: MobileApp
状态: wellFormed | 上次更新: 2025-12-20
ID: c3d4e5f6-a7b8-9012-cdef-123456789012

PlatformService 项目的仓库:
[PlatformService.Api] 默认分支: main, 大小: 24.3 MB
[PlatformService.Web] 默认分支: main, 大小: 18.7 MB
[PlatformService.Infra] 默认分支: develop, 大小: 3.2 MB

流水线管理:触发构建与下载制品

CI/CD 流水线是 Azure DevOps 中最核心的自动化能力之一。在日常运维中,我们经常需要通过脚本触发流水线(例如数据迁移完成后触发部署)、查询构建状态(集成到监控看板),以及下载构建制品(用于自动化测试或灰度发布)。以下代码演示了这三个常见操作的完整实现。

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
# --- 1. 触发流水线构建 ---

$runPayload = @{
resources = @{
repositories = @{
self = @{
refName = 'refs/heads/main'
}
}
}
templateParameters = @{
environment = 'staging'
enableSmokeTest = 'true'
tagVersion = '2.4.1-rc.3'
}
}

$newRun = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/pipelines/42/runs' `
-Method POST `
-PatToken $pat `
-Body $runPayload

Write-Host "已触发流水线运行:"
Write-Host " 运行 ID: $($newRun.id)"
Write-Host " 流水线: $($newRun.pipeline.name)"
Write-Host " 状态: $($newRun.state)"
Write-Host ""

# --- 2. 轮询构建状态直到完成 ---

$runId = $newRun.id
$maxWaitMinutes = 30
$startTime = Get-Date

while (((Get-Date) - $startTime).TotalMinutes -lt $maxWaitMinutes) {
Start-Sleep -Seconds 20

$runStatus = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/pipelines/42/runs/$runId" `
-PatToken $pat

$elapsed = ((Get-Date) - $startTime).ToString('mm\:ss')
Write-Host "[$elapsed] 运行 #$runId 状态: $($runStatus.state)"

if ($runStatus.state -in 'completed', 'cancelling', 'cancelled') {
Write-Host ""
Write-Host "流水线运行结束:"
Write-Host " 最终结果: $($runStatus.result)"
Write-Host " 完成时间: $($runStatus.finishedAt)"
break
}
}

# --- 3. 下载构建制品 ---

if ($runStatus.result -eq 'succeeded') {
$artifacts = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/build/builds/$($newRun.id)/artifacts" `
-PatToken $pat

$downloadDir = Join-Path $HOME "Downloads/AzDo-Artifacts/$($newRun.id)"
New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null

foreach ($artifact in $artifacts.value) {
$downloadUrl = $artifact.resource.downloadUrl
$fileName = "$($artifact.name).zip"
$savePath = Join-Path $downloadDir $fileName

Write-Host "正在下载制品: $($artifact.name) -> $savePath"
Invoke-WebRequest -Uri $downloadUrl -Headers @{
Authorization = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$plainPat")))"
} -OutFile $savePath
}

Write-Host ""
Write-Host "所有制品已下载至: $downloadDir"
Get-ChildItem $downloadDir | ForEach-Object {
Write-Host " $($_.Name) ($([math]::Round($_.Length / 1KB, 1)) KB)"
}
}

这段脚本按照实际运维流程编排了三个步骤:首先触发流水线并传入模板参数(目标环境、是否执行冒烟测试、版本标签),然后以 20 秒间隔轮询运行状态直到完成,最后仅在构建成功时下载所有制品到本地。轮询部分使用时间差而非固定次数,避免长时间运行的流水线被提前终止。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
已触发流水线运行:
运行 ID: 1847
流水线: PlatformService-CI
状态: inProgress

[00:20] 运行 #1847 状态: inProgress
[00:40] 运行 #1847 状态: inProgress
[01:00] 运行 #1847 状态: inProgress
[01:20] 运行 #1847 状态: inProgress
[01:40] 运行 #1847 状态: completed

流水线运行结束:
最终结果: succeeded
完成时间: 2026-02-03T08:23:45.123Z
正在下载制品: drop -> /Users/wubo/Downloads/AzDo-Artifacts/1847/drop.zip
正在下载制品: testResults -> /Users/wubo/Downloads/AzDo-Artifacts/1847/testResults.zip

所有制品已下载至: /Users/wubo/Downloads/AzDo-Artifacts/1847
drop.zip (12456.3 KB)
testResults.zip (3287.5 KB)

工作项查询与冲刺报告生成

Azure DevOps Boards 的工作项数据是团队进度和质量的直接反映。定期从 Boards 中提取数据并生成报告,可以帮助团队及时发现阻塞、评估交付速率、辅助 Sprint 回顾。以下代码使用 WIQL(Work Item Query Language)查询当前冲刺的工作项,并生成一份结构化的文本报告。

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# --- 1. 查询当前冲刺的活跃工作项 ---

$wiqlQuery = @{
query = @"
SELECT [System.Id], [System.WorkItemType], [System.Title],
[System.State], [System.AssignedTo],
[Microsoft.VSTS.Scheduling.StoryPoints],
[Microsoft.VSTS.Common.Priority]
FROM WorkItems
WHERE [System.IterationPath] = @currentIteration()
AND [System.State] NOT IN ('Closed', 'Removed')
AND [System.WorkItemType] IN ('User Story', 'Bug', 'Task')
ORDER BY [Microsoft.VSTS.Common.Priority] ASC
"@
}

$queryResult = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/wit/wiql' `
-Method POST `
-PatToken $pat `
-Body $wiqlQuery

Write-Host "查询到 $($queryResult.workItems.Count) 个活跃工作项"
Write-Host ""

# --- 2. 批量获取工作项详情 ---

$allIds = $queryResult.workItems | Select-Object -ExpandProperty id
$workItems = @()

# API 限制单次最多查询 200 个工作项
$idBatches = $allIds | Group-Object -Property { [math]::Floor([array]::IndexOf($allIds, $_) / 200) }

foreach ($batch in $idBatches) {
$idList = ($batch.Group | ForEach-Object { [int]$_ }) -join ','
$details = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/wit/workitems?ids=$idList&`$expand=fields" `
-PatToken $pat
$workItems += $details.value
}

# --- 3. 生成冲刺报告 ---

Write-Host "=" * 70
Write-Host " PlatformService - Sprint 进度报告"
Write-Host " 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "=" * 70
Write-Host ""

# 按类型分组统计
$byType = $workItems | Group-Object { $_.fields.'System.WorkItemType' }

Write-Host "[按类型统计]"
foreach ($group in $byType) {
$totalPoints = ($group.Group | ForEach-Object {
$_.fields.'Microsoft.VSTS.Scheduling.StoryPoints'
} | Where-Object { $_ } | Measure-Object -Sum).Sum

$pointsStr = if ($totalPoints) { " | 故事点: $totalPoints" } else { '' }
Write-Host " $($group.Name): $($group.Count) 个$pointsStr"
}
Write-Host ""

# 按状态分组统计
$byState = $workItems | Group-Object { $_.fields.'System.State' }

Write-Host "[按状态统计]"
foreach ($group in $byState) {
$pct = [math]::Round($group.Count / $workItems.Count * 100, 1)
$bar = '#' * [math]::Floor($pct / 5)
Write-Host " $($group.Name): $($group.Count) 个 ($pct%) $bar"
}
Write-Host ""

# 按负责人分组统计
$byAssignee = $workItems | Group-Object {
$assigned = $_.fields.'System.AssignedTo'
if ($assigned) { $assigned.displayName } else { '未分配' }
} | Sort-Object Count -Descending

Write-Host "[按负责人统计]"
foreach ($group in $byAssignee) {
Write-Host " $($group.Name): $($group.Count) 个"
foreach ($item in $group.Group) {
$title = $_.fields.'System.Title'
Write-Host " - #$($item.id) $($item.fields.'System.Title')"
}
}
Write-Host ""

# 阻塞项警告
$blockedItems = $workItems | Where-Object {
$_.fields.'System.State' -eq 'Blocked' -or
$_.fields.'System.Tags' -match 'blocked'
}

if ($blockedItems) {
Write-Host "[警告] 存在 $($blockedItems.Count) 个阻塞项:"
foreach ($item in $blockedItems) {
Write-Host " !! #$($item.id) $($item.fields.'System.Title')"
}
}

Write-Host ""
Write-Host "=" * 70
Write-Host " 报告结束"
Write-Host "=" * 70

这段代码使用 WIQL 查询当前冲刺中所有活跃的工作项,然后通过批量接口获取详细字段。报告生成部分按工作项类型、状态、负责人三个维度进行分组统计,并高亮显示被阻塞的工作项。这种脚本非常适合在 Sprint Standup 会议前自动运行,或通过 CI/CD 定时任务发送到团队频道。

执行结果示例:

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
查询到 24 个活跃工作项

======================================================================
PlatformService - Sprint 进度报告
生成时间: 2026-02-03 08:30:15
======================================================================

[按类型统计]
User Story: 8 个 | 故事点: 21
Bug: 6
Task: 10

[按状态统计]
Active: 12 个 (50%) ##############
New: 7 个 (29.2%) ######
Resolved: 3 个 (12.5%) ###
Blocked: 2 个 (8.3%) ##

[按负责人统计]
张三: 8
- #1856 用户注册接口性能优化
- #1861 修复订单列表分页异常
李四: 7
- #1858 实现批量导出功能
- #1863 消息推送服务重构
王五: 5
- #1859 日志采集模块升级
未分配: 4
- #1865 接入新版支付网关

[警告] 存在 2 个阻塞项:
!! #1860 第三方认证服务证书过期
!! #1867 等待上游团队提供接口文档

======================================================================
报告结束
======================================================================

注意事项

  • PAT 权限范围:创建 PAT 时需根据脚本操作范围选择最小权限集。管理项目需要 Project (Read & Write),操作流水线需要 Build (Read & Execute),查询工作项需要 Work Items (Read)。避免使用全权限 PAT,降低凭据泄露后的影响范围。
  • API 版本兼容性:示例中使用 api-version=7.1。Azure DevOps REST API 有 v5.x、v6.x、v7.x 多个主版本,部分接口在不同版本间行为有差异(如流水线 Runs API 在 7.0 后引入了 templateParameters 字段)。生产脚本务必锁定版本号并做兼容性测试。
  • 分页与批量限制:查询类接口(如项目列表、工作项查询)默认有分页限制,通常单次返回 100-1000 条。需要检查响应中的 continuationToken 字段并循环获取完整数据。工作项批量查询单次上限为 200 个 ID,大批量场景需要自行分批。
  • 并发与速率控制:Azure DevOps 对同一组织的 API 调用有速率限制(个人用户约每分钟 600 次)。批量操作时建议使用 Start-Sleep 添加间隔,或使用 PowerShell 的 ForEach-Object -Parallel 配合计数器实现受控并发。
  • 错误重试策略:网络抖动和临时限流会导致偶发的 5xx 或 429 响应。建议封装通用的重试逻辑,对 429 状态码读取 Retry-After 响应头确定等待时间,对 5xx 状态码采用指数退避策略,最多重试 3 次。
  • 敏感信息脱敏:冲刺报告可能包含员工姓名、工作项内容等信息。如果报告需要发送到外部渠道(如邮件、Slack),注意对 System.AssignedTo 等字段做脱敏处理,或将报告输出到 Azure DevOps 内部的 Wiki 页面,保持访问权限的一致性。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本

现代 DevOps 实践中,基础设施即代码(IaC)和自动化测试已经成为标准流程,而 CI/CD 流水线正是将这些实践落地的核心工具。无论是代码提交触发的自动测试,还是合并后自动部署到生产环境,流水线都在其中扮演着承上启下的角色。PowerShell 凭借其强大的系统管理能力和丰富的模块生态,成为了各大 CI/CD 平台中编写构建、测试和部署逻辑的理想选择。

主流 CI/CD 平台(如 GitHub Actions、Azure DevOps、Jenkins)都原生支持运行 PowerShell 脚本。这意味着团队可以用同一门语言编写本地运维脚本和流水线逻辑,减少技术栈切换带来的认知负担。同时,Pester 测试框架可以与流水线深度集成,实现代码质量门控——只有当所有测试用例通过时才允许部署继续推进。

本文将从 GitHub Actions 集成、Azure DevOps 流水线配置和通用流水线工具三个角度,展示如何用 PowerShell 构建健壮的 CI/CD 流水线。

GitHub Actions 中的 PowerShell

GitHub Actions 是目前最流行的 CI/CD 平台之一,它在 Windows、Linux 和 macOS runner 上都原生支持 PowerShell(pwsh)。以下代码展示了如何编写一个完整的 GitHub Actions workflow,包含 Pester 测试、代码质量检查和构建产物发布。

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
# 文件路径: .github/workflows/ci.yml
# 以下为 workflow 配置的 PowerShell 等价描述
# 实际 workflow 文件为 YAML 格式

# === 本地模拟 CI 流水线的 PowerShell 脚本 ===

# 1. 安装测试依赖
Write-Host '=== 步骤 1: 安装依赖 ===' -ForegroundColor Cyan
Install-Module -Name Pester -MinimumVersion 5.5 -Force -Scope CurrentUser
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser

# 2. 运行 PSScriptAnalyzer 代码质量检查
Write-Host '`n=== 步骤 2: 代码质量分析 ===' -ForegroundColor Cyan
$analysisResults = Invoke-ScriptAnalyzer -Path '.\Scripts' -Severity Warning,Error -Recurse
if ($analysisResults) {
$analysisResults | Format-Table -Property RuleName, Severity, ScriptName, Line, Message -Wrap
$errorCount = ($analysisResults | Where-Object Severity -eq 'Error').Count
if ($errorCount -gt 0) {
Write-Host "发现 $errorCount 个错误,流水线终止" -ForegroundColor Red
exit 1
}
} else {
Write-Host '代码质量检查通过,无警告或错误' -ForegroundColor Green
}

# 3. 运行 Pester 测试
Write-Host '`n=== 步骤 3: 运行 Pester 测试 ===' -ForegroundColor Cyan
$pesterConfig = @{
Run = @{
Path = '.\Tests'
PassThru = $true
}
TestResult = @{
Enabled = $true
OutputPath = 'TestResults.xml'
OutputFormat = 'JUnitXml'
}
CodeCoverage = @{
Enabled = $true
Path = '.\Scripts\*.ps1'
OutputPath = 'Coverage.xml'
}
}
$result = Invoke-Pester -Configuration $pesterConfig

# 4. 输出测试摘要
Write-Host "`n测试结果摘要:" -ForegroundColor Yellow
Write-Host " 总计: $($result.TotalCount) 个测试"
Write-Host " 通过: $($result.PassedCount)" -ForegroundColor Green
Write-Host " 失败: $($result.FailedCount)" -ForegroundColor Red
Write-Host " 跳过: $($result.SkippedCount)" -ForegroundColor Gray
Write-Host " 耗时: $($result.Duration.TotalSeconds) 秒"

if ($result.FailedCount -gt 0) {
Write-Host '`n测试未全部通过,流水线终止' -ForegroundColor Red
exit 1
}

# 5. 打包构建产物
Write-Host '`n=== 步骤 4: 打包构建产物 ===' -ForegroundColor Cyan
$artifactName = "ops-scripts-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$artifactPath = "dist\$artifactName"
New-Item -Path $artifactPath -ItemType Directory -Force | Out-Null
Copy-Item -Path '.\Scripts\*' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Modules' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Config\env.template.ps1' -Destination $artifactPath
Compress-Archive -Path $artifactPath -DestinationPath "dist\$artifactName.zip"
Write-Host "构建产物已打包: dist\$artifactName.zip" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
=== 步骤 1: 安装依赖 ===
正在安装 Pester 5.5.x...
正在安装 PSScriptAnalyzer...

=== 步骤 2: 代码质量分析 ===
代码质量检查通过,无警告或错误

=== 步骤 3: 运行 Pester 测试 ===
[+] D:\OpsScripts\Tests\Get-DiskHealth.Tests.ps1 120ms (3 tests)
[+] D:\OpsScripts\Tests\Deploy-App.Tests.ps1 85ms (5 tests)
[+] D:\OpsScripts\Tests\Export-Report.Tests.ps1 42ms (2 tests)

测试结果摘要:
总计: 10 个测试
通过: 10
失败: 0
跳过: 0
耗时: 0.35 秒

=== 步骤 4: 打包构建产物 ===
构建产物已打包: dist\ops-scripts-20260115-083000.zip

Azure DevOps 流水线

Azure DevOps 提供了经典的构建/发布管道和 YAML 管道两种模式,两者都深度集成 PowerShell 执行环境。以下代码展示了如何在 Azure DevOps 中实现多阶段流水线,包括构建验证、 staging 环境部署、审批门控和生产环境发布。

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
98
99
100
101
102
103
104
105
106
107
108
# Azure DevOps Pipeline Agent 中运行的部署脚本
# 文件路径: scripts/Deploy-Application.ps1

param(
[Parameter(Mandatory)]
[ValidateSet('Build', 'Staging', 'Production')]
[string]$Environment,

[string]$ArtifactPath,

[string]$TargetServer
)

# 1. 根据环境加载对应配置
$envConfig = @{
Build = @{
Servers = @('localhost')
Validate = $false
Backup = $false
Notify = $false
}
Staging = @{
Servers = @('staging-web-01', 'staging-web-02')
Validate = $true
Backup = $true
Notify = $false
}
Production = @{
Servers = @('prod-web-01', 'prod-web-02', 'prod-web-03')
Validate = $true
Backup = $true
Notify = $true
}
}
$config = $envConfig[$Environment]

Write-Host "部署环境: $Environment"
Write-Host "目标服务器: $($config.Servers -join ', ')"

# 2. 部署前健康检查
if ($config.Validate) {
Write-Host '`n执行部署前健康检查...' -ForegroundColor Cyan
foreach ($server in $config.Servers) {
$session = New-PSSession -ComputerName $server -ErrorAction Stop
$result = Invoke-Command -Session $session -ScriptBlock {
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
[PSCustomObject]@{
Server = $env:COMPUTERNAME
FreeDiskGB = [math]::Round($disk.FreeSpace / 1GB, 1)
CpuUsage = if ($cpu) { $cpu } else { 0 }
ServicesUp = (Get-Service -Name 'W3SVC','WinRM' |
Where-Object Status -eq 'Running').Count
}
}
$result | Format-Table -AutoSize
if ($result.FreeDiskGB -lt 5) {
throw "服务器 $($result.Server) 磁盘空间不足 ($($result.FreeDiskGB) GB)"
}
Remove-PSSession $session
}
Write-Host '健康检查通过' -ForegroundColor Green
}

# 3. 备份当前版本
if ($config.Backup) {
Write-Host "`n备份当前版本..." -ForegroundColor Cyan
$backupTag = "pre-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($tag)
$appPath = 'D:\WebApp'
$backupPath = "D:\Backups\$tag"
Copy-Item -Path $appPath -Destination $backupPath -Recurse -Force
Write-Host " $env:COMPUTERNAME: 已备份至 $backupPath"
} -ArgumentList $backupTag
}
Write-Host '备份完成' -ForegroundColor Green
}

# 4. 执行部署
Write-Host "`n开始部署..." -ForegroundColor Cyan
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($src)
$appPath = 'D:\WebApp'
# 停止应用
Stop-WebSite -Name 'Default Web Site' -ErrorAction SilentlyContinue
# 复制文件
Copy-Item -Path "$src\*" -Destination $appPath -Recurse -Force
# 启动应用
Start-WebSite -Name 'Default Web Site'
Write-Host " $env:COMPUTERNAME: 部署完成,站点已启动"
} -ArgumentList $ArtifactPath
}

# 5. 部署后验证与通知
if ($config.Notify) {
Write-Host "`n发送部署通知..." -ForegroundColor Cyan
$notifyBody = @{
text = "[部署完成] $Environment 环境已于 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') 完成部署"
} | ConvertTo-Json -Compress
Invoke-RestMethod -Uri $env:WEBHOOK_URL -Method Post `
-Body $notifyBody -ContentType 'application/json'
Write-Host '通知已发送' -ForegroundColor Green
}

Write-Host "`n部署流程全部完成" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
部署环境: Staging
目标服务器: staging-web-01, staging-web-02

执行部署前健康检查...
Server FreeDiskGB CpuUsage ServicesUp
------ ---------- -------- -----------
STAGING-WEB-01 45.2 12 2
STAGING-WEB-02 38.7 8 2
健康检查通过

备份当前版本...
STAGING-WEB-01: 已备份至 D:\Backups\pre-deploy-20260115-090000
STAGING-WEB-02: 已备份至 D:\Backups\pre-deploy-20260115-090000
备份完成

开始部署...
STAGING-WEB-01: 部署完成,站点已启动
STAGING-WEB-02: 部署完成,站点已启动

部署流程全部完成

通用流水线工具

无论使用哪个 CI/CD 平台,一些通用的流水线工具都是不可或缺的。以下代码封装了代码质量检查、语义版本计算和变更日志自动生成三个常用功能,可以作为独立脚本被任何流水线调用。

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# 文件路径: scripts/Pipeline-Utils.ps1
# 通用流水线工具函数集

# --- 函数 1: 代码质量门控检查 ---
function Invoke-CodeQualityGate {
[CmdletBinding()]
param(
[string]$Path = '.\Scripts',
[int]$MaxWarnings = 10,
[int]$MaxErrors = 0
)
$results = Invoke-ScriptAnalyzer -Path $Path -Recurse -Severity Information,Warning,Error
$summary = $results | Group-Object Severity | ForEach-Object {
@{ Severity = $_.Name; Count = $_.Count }
}
$errorCount = ($results | Where-Object Severity -eq 'Error').Count
$warningCount = ($results | Where-Object Severity -eq 'Warning').Count

Write-Host "代码质量报告:" -ForegroundColor Yellow
Write-Host " 错误: $errorCount (阈值: $MaxErrors)"
Write-Host " 警告: $warningCount (阈值: $MaxWarnings)"

$passed = ($errorCount -le $MaxErrors) -and ($warningCount -le $MaxWarnings)
if ($passed) {
Write-Host ' 结果: 通过' -ForegroundColor Green
} else {
Write-Host ' 结果: 未通过' -ForegroundColor Red
}
return $passed
}

# --- 函数 2: 语义版本计算 ---
function Get-NextSemanticVersion {
[CmdletBinding()]
param(
[string]$TagPrefix = 'v',
[string]$DefaultVersion = '0.1.0'
)
$latestTag = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "未找到已有标签,使用默认版本: $DefaultVersion"
return [version]$DefaultVersion
}
$versionStr = $latestTag -replace "^$([regex]::Escape($TagPrefix))"
$current = [version]$versionStr
$messages = git log "$latestTag..HEAD" --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新的提交,版本不变'
return $current
}
$bump = 'Patch'
if ($messages -match '^feat(\(|:)') { $bump = 'Minor' }
if ($messages -match 'BREAKING CHANGE') { $bump = 'Major' }
$next = switch ($bump) {
'Major' { [version]::new($current.Major + 1, 0, 0) }
'Minor' { [version]::new($current.Major, $current.Minor + 1, 0) }
'Patch' { [version]::new($current.Major, $current.Minor, $current.Build + 1) }
}
Write-Host "版本: $current -> $next (递增: $bump)"
return $next
}

# --- 函数 3: 变更日志自动生成 ---
function New-ChangeLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[version]$Version,
[string]$TagPrefix = 'v'
)
$latestTag = git describe --tags --abbrev=0 2>$null
$range = if ($LASTEXITCODE -eq 0) { "$latestTag..HEAD" } else { 'HEAD' }
$messages = git log $range --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新变更,跳过 CHANGELOG 生成'
return
}

$lines = @(
"## [$Version] - $(Get-Date -Format 'yyyy-MM-dd')"
)

$features = $messages | Where-Object { $_ -match '^feat' }
$fixes = $messages | Where-Object { $_ -match '^fix' }
$others = $messages | Where-Object {
$_ -notmatch '^feat' -and $_ -notmatch '^fix' -and $_ -notmatch '^chore'
}

if ($features) {
$lines += '', '### 新功能'
$features | ForEach-Object { $lines += "- $_" }
}
if ($fixes) {
$lines += '', '### 修复'
$fixes | ForEach-Object { $lines += "- $_" }
}
if ($others) {
$lines += '', '### 其他变更'
$others | ForEach-Object { $lines += "- $_" }
}

$changeLogPath = 'CHANGELOG.md'
$existing = if (Test-Path $changeLogPath) { Get-Content $changeLogPath -Raw } else { '' }
$newContent = ($lines -join "`n") + "`n`n" + $existing
Set-Content -Path $changeLogPath -Value $newContent.TrimEnd() -Encoding UTF8
Write-Host "CHANGELOG 已更新: $changeLogPath" -ForegroundColor Green
}

# --- 主流程: 调用示例 ---
Write-Host '========== 流水线工具执行 ==========' -ForegroundColor Cyan

# 运行代码质量检查
$qualityOk = Invoke-CodeQualityGate -Path '.\Scripts' -MaxWarnings 10 -MaxErrors 0

# 计算下一个版本号
$nextVersion = Get-NextSemanticVersion -TagPrefix 'v'
Write-Host "下一个发布版本: $nextVersion"

# 生成变更日志
New-ChangeLog -Version $nextVersion -TagPrefix 'v'

if ($qualityOk) {
Write-Host "`n所有门控检查通过,可以继续发布流程" -ForegroundColor Green
} else {
Write-Host "`n门控检查未通过,请修复后重试" -ForegroundColor Red
exit 1
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
========== 流水线工具执行 ==========

代码质量报告:
错误: 0 (阈值: 0)
警告: 3 (阈值: 10)
结果: 通过

版本: 1.0.0 -> 1.1.0 (递增: Minor)
下一个发布版本: 1.1.0

CHANGELOG 已更新: CHANGELOG.md

所有门控检查通过,可以继续发布流程

注意事项

  1. 流水线中的执行策略:CI/CD runner 上的 PowerShell 执行策略可能限制脚本运行。在 workflow 中显式设置 pwsh -ExecutionPolicy Bypass -File script.ps1,或在脚本开头使用 Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned 来确保脚本能正常执行。

  2. 敏感信息管理:绝不要将 API 密钥、连接字符串等敏感信息硬编码在脚本中。GitHub Actions 使用 Secrets,Azure DevOps 使用 Variable Groups 配合 Key Vault,Jenkins 使用 Credentials Binding。在流水线中通过环境变量($env:API_KEY)引用这些安全值。

  3. Pester 测试的隔离性:每个测试用例应该独立运行,不依赖其他测试的执行顺序或状态。使用 BeforeAllAfterAll 进行测试环境的初始化和清理,避免测试之间产生副作用,尤其是在流水线中并发执行测试的场景。

  4. 幂等部署设计:部署脚本必须是幂等的——多次执行的结果与一次执行的结果相同。在部署前先检查目标状态,如果已经是期望状态则跳过操作。这样可以安全地重试失败的部署步骤,而不会产生重复创建或数据损坏等问题。

  5. 跨平台兼容性:如果流水线可能在 Linux 或 macOS runner 上运行,避免使用 Windows 专用的 cmdlet(如 Stop-WebSiteNew-PSSession)。改用 REST API 或 SSH 方式进行远程管理,并使用 Join-Path 代替硬编码路径分隔符,确保脚本在不同操作系统上行为一致。

  6. 流水线日志与遥测:在关键步骤添加结构化的日志输出(如 ::group::::set-output name=key::value),便于在流水线界面中快速定位问题。对于长时间运行的部署,建议集成 Slack 或 Teams Webhook 通知,让团队实时感知部署进度和结果。

PowerShell 技能连载 - Azure DevOps 集成

适用于 PowerShell 5.1 及以上版本

Azure DevOps 是微软提供的一站式 DevOps 平台,涵盖了 Boards(工作项跟踪)、Repos(代码仓库)、Pipelines(CI/CD 流水线)、Test Plans(测试管理)和 Artifacts(制品管理)五大核心服务。在企业级开发流程中,团队往往需要通过脚本自动化地与 Azure DevOps 交互,例如批量创建工作项、触发流水线、查询构建状态或管理代码仓库分支策略。

虽然 Azure DevOps 提供了功能完善的 Web 界面和 CLI 工具(az devops),但 PowerShell 凭借其强大的对象处理能力和与其他 Windows/Azure 服务的无缝集成,仍然是许多运维和开发团队的首选自动化工具。通过 Azure DevOps REST API,我们可以在 PowerShell 中完成几乎所有的平台操作,并将这些操作编排到更大的自动化工作流中。

本文将介绍如何使用 PowerShell 调用 Azure DevOps REST API,涵盖身份认证与连接管理、工作项(Work Item)的批量操作、Pipeline 的触发与状态监控,以及代码仓库的分支策略管理。每个场景都配有可直接运行的代码示例和执行结果演示。

准备工作:身份认证与连接封装

Azure DevOps REST API 支持多种身份认证方式,其中最常用的是 Personal Access Token(PAT)。为了在脚本中安全地使用 PAT,我们需要将认证逻辑封装成可复用的函数,避免在代码中硬编码凭据。以下代码演示了如何创建一个通用的 Azure DevOps 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
52
53
54
55
56
57
58
59
60
function Invoke-AzDevOpsApi {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Organization,

[Parameter(Mandatory)]
[string]$Project,

[Parameter(Mandatory)]
[string]$ApiPath,

[Parameter(Mandatory)]
[securestring]$PatToken,

[ValidateSet('GET', 'POST', 'PATCH', 'DELETE')]
[string]$Method = 'GET',

[object]$Body
)

$base64Token = [Convert]::ToBase64String(
[System.Text.Encoding]::ASCII.GetBytes(
':' + (New-Object PSCredential('user', $PatToken).GetNetworkCredential().Password)
)
)

$headers = @{
Authorization = "Basic $base64Token"
'Content-Type' = 'application/json'
}

$uri = "https://dev.azure.com/$Organization/$Project/_apis$ApiPath"

$splat = @{
Uri = $uri
Headers = $headers
Method = $Method
}

if ($Body) {
$splat.Body = ($Body | ConvertTo-Json -Depth 10)
}

Invoke-RestMethod @splat
}

# 从环境变量读取 PAT,避免硬编码
$pat = ConvertTo-SecureString $env:AZDO_PAT -AsPlainText -Force

# 测试连接:获取项目信息
$projectInfo = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '?api-version=7.0' `
-PatToken $pat

Write-Host "项目名称: $($projectInfo.name)"
Write-Host "项目描述: $($projectInfo.description)"
Write-Host "项目 ID: $($projectInfo.id)"

上述代码将 PAT 以 SecureString 形式传入,通过 Base64 编码生成 Basic Auth 头。Invoke-AzDevOpsApi 函数封装了 URI 拼接和请求发送逻辑,后续所有示例都基于此函数调用。

执行结果示例:

1
2
3
项目名称: MyProject
项目描述: 核心业务系统开发项目
项目 ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

批量创建与查询工作项

Azure DevOps Boards 中的工作项(Work Item)是项目管理的基础单元。当需要从外部系统同步需求、批量创建测试任务或在 Sprint 规划时一次性添加多个用户故事时,手动操作效率极低。以下代码展示了如何批量创建工作项并查询特定条件的工作项列表。

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
# 批量创建用户故事(User Story)
$stories = @(
@{ Title = '实现用户登录 API 接口'; Priority = 1 },
@{ Title = '添加 OAuth2.0 第三方登录支持'; Priority = 2 },
@{ Title = '实现登录失败次数限制策略'; Priority = 3 }
)

foreach ($story in $stories) {
$body = @(
@{
op = 'add'
path = '/fields/System.Title'
value = $story.Title
},
@{
op = 'add'
path = '/fields/Microsoft.VSTS.Common.Priority'
value = $story.Priority
}
)

$result = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/wit/workitems/$User Story?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $body

Write-Host "已创建工作项 #$($result.id) - $($result.fields.'System.Title')"
}

# 查询当前迭代中所有未关闭的 Bug
$wiql = @{
query = "SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] `
FROM WorkItems `
WHERE [System.WorkItemType] = 'Bug' `
AND [System.State] <> 'Closed' `
AND [System.IterationPath] = @currentIteration() `
ORDER BY [System.CreatedDate] DESC"
}

$queryResult = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/wit/wiql?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $wiql

$workItemIds = $queryResult.workItems | Select-Object -ExpandProperty id
Write-Host "当前迭代中未关闭的 Bug 数量: $($workItemIds.Count)"

Azure DevOps 创建工作项使用 JSON Patch 格式(op: add),通过 path 指定要设置的字段。WIQL(Work Item Query Language)是类似 SQL 的查询语言,@currentIteration() 函数可以自动定位当前冲刺周期。使用 foreach 循环逐条创建可以清晰地在输出中追踪每条记录的创建结果。

执行结果示例:

1
2
3
4
已创建工作项 #1247 - 实现用户登录 API 接口
已创建工作项 #1248 - 添加 OAuth2.0 第三方登录支持
已创建工作项 #1249 - 实现登录失败次数限制策略
当前迭代中未关闭的 Bug 数量: 8

触发 Pipeline 并监控构建状态

在持续集成/持续部署(CI/CD)流程中,有时需要通过脚本手动触发 Pipeline,例如在完成数据迁移后触发部署流水线,或按需触发特定的测试流水线。以下代码演示了如何触发 Pipeline 构建,并以轮询方式等待构建完成。

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
# 触发 Pipeline 构建
$buildPayload = @{
definition = @{
id = 42
}
parameters = '{"environment":"staging","runTests":true}'
}

$build = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/build/builds?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $buildPayload

Write-Host "已触发构建 #$($build.id)"
Write-Host "构建定义: $($build.definition.name)"
Write-Host "初始状态: $($build.status)"
Write-Host "队列时间: $($build.queueTime)"

# 轮询构建状态直到完成
$buildId = $build.id
$maxRetries = 60
$retryCount = 0

while ($retryCount -lt $maxRetries) {
Start-Sleep -Seconds 30

$status = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath "/build/builds/$buildId`?api-version=7.0" `
-PatToken $pat

Write-Host "[$($retryCount + 1)] 构建 #$buildId 状态: $($status.status) - $($status.result)"

if ($status.status -eq 'completed') {
Write-Host "`n构建完成! 最终结果: $($status.result)"
Write-Host "开始时间: $($status.startTime)"
Write-Host "完成时间: $($status.finishTime)"

$duration = [datetime]::Parse($status.finishTime) - [datetime]::Parse($status.startTime)
Write-Host "耗时: $($duration.TotalMinutes.ToString('F1')) 分钟"
break
}

$retryCount++
}

if ($retryCount -ge $maxRetries) {
Write-Warning "等待超时,构建 #$buildId 仍未完成,请手动检查。"
}

这段代码首先通过 POST 请求触发指定 ID 的 Pipeline 定义,同时传递模板参数(environmentrunTests)。触发成功后进入轮询循环,每 30 秒查询一次构建状态,直到状态变为 completed 或达到最大重试次数。循环结束时计算并输出构建耗时,方便排查流水线性能问题。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
已触发构建 #3891
构建定义: MyProject-CI
初始状态: inProgress
队列时间: 2025-11-26T02:15:33.287Z
[1] 构建 #3891 状态: inProgress -
[2] 构建 #3891 状态: inProgress -
[3] 构建 #3891 状态: inProgress -
[4] 构建 #3891 状态: completed - succeeded

构建完成! 最终结果: succeeded
开始时间: 2025-11-26T02:15:38.412Z
完成时间: 2025-11-26T02:17:45.891Z
耗时: 2.1 分钟

管理代码仓库分支策略

分支策略(Branch Policy)是保障代码质量的重要手段。在团队协作中,通常要求所有代码变更通过 Pull Request 提交,并设置最低审核人数、构建验证和合并策略。以下代码演示了如何通过 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 获取仓库 ID
$repo = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/git/repositories?api-version=7.0' `
-PatToken $pat

$targetRepo = $repo.value | Where-Object { $_.name -eq 'MyApp' }
Write-Host "目标仓库: $($targetRepo.name) (ID: $($targetRepo.id))"

# 获取默认分支的 ref
$defaultBranch = $targetRepo.defaultBranch
Write-Host "默认分支: $defaultBranch"

# 获取分支策略配置列表
$policyConfigurations = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/policy/configurations?api-version=7.0' `
-PatToken $pat

# 统计当前项目的策略数量
$enabledPolicies = @($policyConfigurations.value | Where-Object { $_.isEnabled -eq $true })
Write-Host "当前项目已启用的策略数量: $($enabledPolicies.Count)"

# 为 main 分支创建"最少审核人数"策略
$minReviewersPolicy = @{
isEnabled = $true
isBlocking = $true
type = @{
id = 'fa4e907d-c16b-4a4c-90b4-75ae827c5881'
}
settings = @{
minimumApproverCount = 2
creatorVoteCounts = $false
scope = @(
@{
refName = $defaultBranch
matchKind = 'exact'
repositoryId = $targetRepo.id
}
)
}
}

$newPolicy = Invoke-AzDevOpsApi `
-Organization 'mycompany' `
-Project 'MyProject' `
-ApiPath '/policy/configurations?api-version=7.0' `
-Method POST `
-PatToken $pat `
-Body $minReviewersPolicy

Write-Host "已创建策略: 最低审核人数 = 2"
Write-Host "策略 ID: $($newPolicy.id)"
Write-Host "是否阻断: $($newPolicy.isBlocking)"

分支策略的类型通过 GUID 标识。fa4e907d-c16b-4a4c-90b4-75ae828c5881 代表”最少审核人数”策略类型。isBlocking = $true 表示不满足策略要求时无法合并 Pull Request。creatorVoteCounts = $false 确保创建者自身的审核不计入最低审核人数。通过脚本配置策略,可以确保新仓库的分支保护规则与团队规范一致。

执行结果示例:

1
2
3
4
5
6
目标仓库: MyApp (ID: abc12345-6789-def0-1234-567890abcdef)
默认分支: refs/heads/main
当前项目已启用的策略数量: 5
已创建策略: 最低审核人数 = 2
策略 ID: 9876abcd-5432-10fe-dc98-76543210fedc
是否阻断: True

注意事项

  • PAT 安全管理:切勿将 Personal Access Token 硬编码在脚本中。推荐从环境变量($env:AZDO_PAT)或 Azure Key Vault 中读取,并在 CI/CD 流水线中使用变量组(Variable Group)的机密引用功能。
  • API 版本控制:Azure DevOps REST API 要求在每次请求中指定 api-version 参数。建议在生产脚本中固定使用某个已验证的版本号(如 7.07.1),避免因 API 升级导致脚本行为变化。
  • 请求频率限制:Azure DevOps 对 REST API 调用有频率限制(通常为每小时 6000 次,具体取决于组织规模)。批量操作时建议在循环中添加适当延时(如 Start-Sleep -Milliseconds 200),或在遇到 429 状态码时实现指数退避重试。
  • JSON Patch 格式差异:创建工作项使用 JSON Patch 数组格式(op: add),而更新工作项也使用同一格式但允许 op: replaceop: remove 等操作。注意这与常规 REST API 的 PUT/PATCH JSON body 格式不同,混淆两者是常见的调试陷阱。
  • 错误处理Invoke-RestMethod 默认在遇到非 2xx 状态码时抛出异常。建议在调用外层包裹 try/catch 块,并通过 $_.Exception.Response 获取详细的错误响应内容,便于定位问题。
  • 跨平台兼容性:如果需要在 PowerShell 7+ 的 Linux/macOS 环境中运行,注意 ConvertTo-SecureString 在非 Windows 平台上的行为差异。推荐使用跨平台兼容的凭据管理方式,例如直接通过 Invoke-RestMethod-Authentication Bearer 参数传递令牌。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本(跨平台)

持续集成与持续交付(CI/CD)是现代 DevOps 实践的核心环节。无论是 GitHub Actions、Azure DevOps 还是 GitLab CI,流水线的编排本质上都是将一系列自动化步骤串联起来:代码拉取、依赖安装、测试执行、构建打包、环境部署。PowerShell 作为跨平台的脚本语言,天然适合承担这些步骤的”粘合剂”角色——它既能调用系统命令,又能解析结构化数据,还能与 REST API 交互,是构建 CI/CD 流水线的利器。

很多团队在编写 CI/CD 脚本时仍然依赖 Bash 或 Python,但 PowerShell 在 Windows 和 Linux 上行为一致的特性,加上对 JSON、XML、YAML 的原生支持,使得同一套脚本可以在不同运行器(runner)上无缝切换。尤其是在混合环境中管理 .NET 项目、Azure 资源或 Windows 工作负载时,PowerShell 的优势更加明显。将流水线逻辑封装为 PowerShell 模块后,还能实现跨仓库复用,减少重复维护成本。

本文将通过三个实战场景,展示如何用 PowerShell 构建可测试、可复用、可观测的 CI/CD 自动化脚本:包括流水线阶段编排与结果报告、版本号自动管理、以及部署前置检查。每个示例都尽量贴近真实项目中的使用方式,帮助你快速将 PowerShell 集成到现有流水线中。

流水线阶段编排与报告

在复杂的 CI/CD 流水线中,我们通常需要将构建过程拆分为多个阶段(如 lint、test、build、deploy),每个阶段可能包含若干步骤。以下脚本展示了如何用 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
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
# 定义流水线阶段
$stages = @(
@{
Name = 'Lint'
Script = { markdownlint source/_posts/*.md }
Critical = $false
},
@{
Name = 'Test'
Script = { Invoke-Pester -Path './tests' -Output Minimal }
Critical = $true
},
@{
Name = 'Build'
Script = { npx hexo generate }
Critical = $true
},
@{
Name = 'Deploy'
Script = { npx hexo deploy }
Critical = $true
}
)

# 执行每个阶段并记录结果
$results = foreach ($stage in $stages) {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$success = $false
$errorMessage = ''

Write-Host "`n========== Stage: $($stage.Name) ==========" -ForegroundColor Cyan

try {
$output = & $stage.Script 2>&1
$success = $LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE
if (-not $success) {
$errorMessage = ($output | Select-String -Pattern 'error|fail' -SimpleMatch |
Select-Object -First 3) -join '; '
}
}
catch {
$success = $false
$errorMessage = $_.Exception.Message
}

$stopwatch.Stop()

$statusIcon = if ($success) { 'PASS' } else { 'FAIL' }
Write-Host " [$statusIcon] $($stage.Name) ($($stopwatch.Elapsed.ToString('mm\:ss')))" -ForegroundColor $(if ($success) { 'Green' } else { 'Red' })

[PSCustomObject]@{
Stage = $stage.Name
Status = if ($success) { 'Passed' } else { 'Failed' }
Duration = $stopwatch.Elapsed
Critical = $stage.Critical
Error = $errorMessage
}

# 关键阶段失败则中止流水线
if (-not $success -and $stage.Critical) {
Write-Host "`n流水线因关键阶段 [$($stage.Name)] 失败而中止。" -ForegroundColor Red
break
}
}

# 输出汇总报告
Write-Host "`n===== Pipeline Summary =====" -ForegroundColor Yellow
foreach ($r in $results) {
$icon = if ($r.Status -eq 'Passed') { '[PASS]' } else { '[FAIL]' }
Write-Host (" {0} {1} ({2})" -f $icon, $r.Stage, $r.Duration.ToString('mm\:ss'))
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
========== Stage: Lint ==========
[PASS] Lint (00:03)

========== Stage: Test ==========
[PASS] Test (00:12)

========== Stage: Build ==========
[FAIL] Build (00:08)

流水线因关键阶段 [Build] 失败而中止。

===== Pipeline Summary =====
[PASS] Lint (00:03)
[PASS] Test (00:12)
[FAIL] Build (00:08)

这段脚本的核心设计是 $stages 数组,每个阶段用哈希表描述名称、执行脚本块和是否为关键阶段。Critical 标记决定该阶段失败后是否中止后续阶段。执行结果收集到 $results 中,最终输出汇总报告。这种方式比在 YAML 中堆砌大量 if-failure 条件更直观,也便于在本地调试时单独运行某个阶段。

自动化版本号管理

语义化版本(Semantic Versioning)是 CI/CD 中版本发布的基石。每次发布前手动修改版本号既容易出错,又难以追溯。以下脚本展示了如何基于 Git 标签自动计算下一个版本号,并更新项目文件。

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
# 获取当前最新 Git 标签
$latestTag = git tag --sort=-v:refname | Select-Object -First 1

if ($latestTag -match '^v(\d+)\.(\d+)\.(\d+)$') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
$patch = [int]$Matches[3]
}
else {
# 没有有效标签时使用默认版本
$major, $minor, $patch = 0, 1, 0
}

# 根据本次提交的变更内容决定版本递增策略
$commitMessages = git log "$latestTag..HEAD" --pretty=format:'%s'
$bumpType = 'patch' # 默认递增补丁号

foreach ($msg in $commitMessages) {
if ($msg -match '^feat!?:') {
# 包含 feat 提交时递增次版本号
$bumpType = 'minor'
}
if ($msg -match 'BREAKING CHANGE') {
# 包含破坏性变更时递增主版本号
$bumpType = 'major'
break
}
}

# 计算新版本号
switch ($bumpType) {
'major' { $major++; $minor = 0; $patch = 0 }
'minor' { $minor++; $patch = 0 }
'patch' { $patch++ }
}

$newVersion = "{0}.{1}.{2}" -f $major, $minor, $patch
$prerelease = ''
if ($env:GITHUB_REF -and $env:GITHUB_REF -ne "refs/heads/main") {
# 非主分支自动添加预发布标识
$shortSha = git rev-parse --short HEAD
$prerelease = "-beta.$shortSha"
}

$fullVersion = "v${newVersion}${prerelease}"

Write-Host "上一个版本: $latestTag"
Write-Host "版本递增策略: $bumpType"
Write-Host "新版本号: $fullVersion"

# 输出到 GitHub Actions 环境变量(如果在 CI 中运行)
if ($env:GITHUB_OUTPUT) {
"version=$fullVersion" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
"numeric_version=$newVersion" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}

执行结果示例:

1
2
3
上一个版本: v1.3.2
版本递增策略: minor
新版本号: v1.4.0

这段脚本遵循 Conventional Commits 规范来决定版本递增策略:遇到 feat: 提交递增次版本号,遇到 BREAKING CHANGE 递增主版本号,其余情况递增补丁号。非主分支的构建会自动追加预发布标识。通过 $env:GITHUB_OUTPUT 将结果写入 GitHub Actions 的输出变量,后续步骤可以直接引用 ${{ steps.version.outputs.version }}

部署前置检查脚本

在执行正式部署之前,进行一系列环境就绪检查可以有效防止生产事故。以下脚本演示了如何对目标环境进行健康检查、配置验证和依赖可用性探测。

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# 部署目标配置
$targetConfig = @{
AppName = 'my-webapp'
HealthUrl = 'https://my-webapp.example.com/health'
MinDiskGB = 10
RequiredEnv = @('DATABASE_URL', 'REDIS_URL', 'JWT_SECRET')
DatabaseHost = 'db.internal.example.com'
DatabasePort = 5432
}

$checks = @(
@{
Name = '磁盘空间'
Test = {
$drive = Get-PSDrive -Name ($IsLinux ? '/' : 'C')
$freeGB = [math]::Round($drive.Free / 1GB, 2)
$freeGB -ge $targetConfig.MinDiskGB
}
Detail = {
$drive = Get-PSDrive -Name ($IsLinux ? '/' : 'C')
$freeGB = [math]::Round($drive.Free / 1GB, 2)
"可用 {0:N2} GB(要求 >= {1} GB)" -f $freeGB, $targetConfig.MinDiskGB
}
},
@{
Name = '环境变量'
Test = {
$missing = @()
foreach ($envName in $targetConfig.RequiredEnv) {
if (-not [Environment]::GetEnvironmentVariable($envName)) {
$missing += $envName
}
}
$missing.Count -eq 0
}
Detail = {
$missing = @()
foreach ($envName in $targetConfig.RequiredEnv) {
if (-not [Environment]::GetEnvironmentVariable($envName)) {
$missing += $envName
}
}
if ($missing.Count -gt 0) {
"缺少: " + ($missing -join ', ')
}
else {
'所有必需环境变量已配置'
}
}
},
@{
Name = '数据库连通性'
Test = {
try {
$tcp = [System.Net.Sockets.TcpClient]::new()
$tcp.Connect($targetConfig.DatabaseHost, $targetConfig.DatabasePort)
$tcp.Close()
$true
}
catch {
$false
}
}
Detail = {
"{0}:{1} TCP 连接测试" -f $targetConfig.DatabaseHost, $targetConfig.DatabasePort
}
},
@{
Name = '应用健康端点'
Test = {
try {
$response = Invoke-WebRequest -Uri $targetConfig.HealthUrl `
-TimeoutSec 10 -UseBasicParsing
$response.StatusCode -eq 200
}
catch {
$false
}
}
Detail = {
"GET $($targetConfig.HealthUrl)"
}
}
)

# 执行所有检查
Write-Host "Deploy Pre-flight Checks for [$($targetConfig.AppName)]`n" -ForegroundColor Cyan

$allPassed = $true
foreach ($check in $checks) {
$passed = & $check.Test
$detail = & $check.Detail

$icon = if ($passed) { '[PASS]' } else { '[FAIL]' }
$color = if ($passed) { 'Green' } else { 'Red' }
Write-Host (" {0} {1} - {2}" -f $icon, $check.Name, $detail) -ForegroundColor $color

if (-not $passed) {
$allPassed = $false
}
}

# 根据检查结果决定是否继续部署
if ($allPassed) {
Write-Host "`n所有检查通过,可以继续部署。" -ForegroundColor Green
if ($env:GITHUB_OUTPUT) {
"deploy_ready=true" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}
}
else {
Write-Host "`n存在未通过的检查项,请修复后再部署。" -ForegroundColor Red
if ($env:GITHUB_OUTPUT) {
"deploy_ready=false" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}
exit 1
}

执行结果示例:

1
2
3
4
5
6
7
8
Deploy Pre-flight Checks for [my-webapp]

[PASS] 磁盘空间 - 可用 45.32 GB(要求 >= 10 GB)
[FAIL] 环境变量 - 缺少: JWT_SECRET
[PASS] 数据库连通性 - db.internal.example.com:5432 TCP 连接测试
[PASS] 应用健康端点 - GET https://my-webapp.example.com/health

存在未通过的检查项,请修复后再部署。

这个脚本的架构是”配置 + 检查列表”模式。每个检查项包含 Test(判断是否通过)和 Detail(输出具体信息)两个脚本块。这种声明式的写法使得新增检查项非常简单——只需在 $checks 数组中追加一个哈希表即可。检查结果通过 $env:GITHUB_OUTPUT 传递给后续流水线步骤,实现”前置检查通过才执行部署”的门控逻辑。

注意事项

  1. 跨平台兼容性:PowerShell 7 在 Linux 和 macOS 上的行为与 Windows 基本一致,但部分命令存在差异。例如 Get-PSDrive 在 Linux 上只返回文件系统挂载点,Windows 上则包含注册表驱动器。编写跨平台脚本时应充分测试,可利用 $IsLinux$IsMacOS$IsWindows 自动变量做条件分支。

  2. 错误处理策略:CI/CD 环境中的脚本应始终使用 $ErrorActionPreference = 'Stop',确保未捕获的异常立即终止脚本,避免”静默失败”导致错误的部署结果。对于预期可能失败的步骤(如网络探测),应使用 try/catch 显式捕获异常并记录原因。

  3. 敏感信息管理:流水线脚本中切勿硬编码密码、Token 等敏感信息。应通过 CI/CD 平台的安全变量(GitHub Secrets、Azure DevOps Variable Groups)注入,脚本通过 $env:SECRET_NAME 读取。输出日志时注意脱敏,避免 Write-Host 打印包含凭据的变量。

  4. 幂等性设计:部署脚本应当是幂等的——重复执行不应产生副作用。例如创建目录前先检查是否已存在,数据库迁移脚本应判断变更是否已应用。这样当流水线因网络超时等原因重试时,不会导致重复部署或数据损坏。

  5. 日志与可观测性:CI/CD 脚本应输出结构化的执行日志,包括时间戳、阶段名称和执行结果。建议在关键节点使用 GitHub Actions 的 ::group::::endgroup:: 标记对日志进行分组,方便在流水线界面中折叠和展开。对于长时间运行的步骤,还应输出进度信息以便排查卡顿。

  6. 脚本模块化与测试:将流水线逻辑封装为 .psm1 模块后,可以使用 Pester 编写单元测试,确保每次修改不会引入回归。模块化的另一个好处是跨仓库复用——通过 Git Submodule 或 PowerShell Gallery 分发,多个项目可以共享同一套经过验证的部署脚本,减少维护负担。

PowerShell 技能连载 - 管道性能优化

适用于 PowerShell 5.1 及以上版本

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
# 测量不同方式的性能差异
$items = 1..100000

# 方式 1:管道 + ForEach-Object
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$result1 = $items | ForEach-Object { $_ * 2 }
$sw.Stop()
Write-Host "管道 ForEach-Object:$($sw.ElapsedMilliseconds) ms,结果数:$($result1.Count)"

# 方式 2:赋值变量 + foreach 语句
$sw.Restart()
$result2 = foreach ($item in $items) { $item * 2 }
$sw.Stop()
Write-Host "foreach 语句:$($sw.ElapsedMilliseconds) ms,结果数:$($result2.Count)"

# 方式 3:LINQ(PowerShell 7+)
$sw.Restart()
$result3 = [System.Linq.Enumerable]::Select(
[int[]]$items, [Func[int, int]]{ param($x) $x * 2 }
)
$sw.Stop()
Write-Host "LINQ Select:$($sw.ElapsedMilliseconds) ms,结果数:$($result3.Count)"

# 方式 4:List 累积器
$sw.Restart()
$list = [System.Collections.Generic.List[int]]::new($items.Count)
foreach ($item in $items) { $list.Add($item * 2) }
$sw.Stop()
Write-Host "List 累积器:$($sw.ElapsedMilliseconds) ms,结果数:$($list.Count)"

执行结果示例:

1
2
3
4
管道 ForEach-Object3456 ms,结果数:100000
foreach 语句:234 ms,结果数:100000
LINQ Select45 ms,结果数:100000
List 累积器:189 ms,结果数:100000

管道瓶颈分析

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
# 分析 Where-Object 的性能问题
$processes = Get-Process

# 慢速:管道 + 脚本块
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$heavy1 = $processes | Where-Object { $_.WorkingSet64 -gt 50MB }
$sw.Stop()
Write-Host "管道 Where-Object:$($sw.ElapsedMilliseconds) ms"

# 快速:直接用 .NET 方法
$sw.Restart()
$heavy2 = [System.Linq.Enumerable]::Where(
[System.Diagnostics.Process[]]$processes,
[Func[System.Diagnostics.Process, bool]]{ param($p) $p.WorkingSet64 -gt 50MB }
)
$sw.Stop()
Write-Host "LINQ Where:$($sw.ElapsedMilliseconds) ms"

# 快速:foreach 语句 + 条件
$sw.Restart()
$heavy3 = foreach ($p in $processes) {
if ($p.WorkingSet64 -gt 50MB) { $p }
}
$sw.Stop()
Write-Host "foreach + 条件:$($sw.ElapsedMilliseconds) ms"

# 分析内存使用
$data = 1..50000
$before = [GC]::GetTotalMemory($true)

# 管道方式产生更多临时对象
$null = $data | ForEach-Object { $_ * 2 }
$after = [GC]::GetTotalMemory($false)
Write-Host "`n管道内存增量:$([math]::Round(($after - $before) / 1KB)) KB"

$before = [GC]::GetTotalMemory($true)
$null = foreach ($item in $data) { $item * 2 }
$after = [GC]::GetTotalMemory($false)
Write-Host "foreach 内存增量:$([math]::Round(($after - $before) / 1KB)) KB"

执行结果示例:

1
2
3
4
5
6
管道 Where-Object:89 ms
LINQ Where:12 ms
foreach + 条件:8 ms

管道内存增量:1280 KB
foreach 内存增量:320 KB

大数据集处理优化

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
# 优化文件处理:避免一次性加载全部内容
function Get-LargeFileStats {
param([string]$Path, [int]$SampleRate = 1)

$totalCount = 0
$totalSize = 0L
$extensions = @{}

$sw = [System.Diagnostics.Stopwatch]::StartNew()

# 使用 EnumerateFiles 避免一次性加载所有 FileInfo
$files = [System.IO.Directory]::EnumerateFiles($Path, "*", [System.IO.SearchOption]::AllDirectories)

foreach ($file in $files) {
$totalCount++
if ($totalCount % $SampleRate -ne 0) { continue }

$info = [System.IO.FileInfo]::new($file)
$totalSize += $info.Length

$ext = $info.Extension
if (-not $ext) { $ext = "(无扩展名)" }
if (-not $extensions.ContainsKey($ext)) {
$extensions[$ext] = @{ Count = 0; Size = 0L }
}
$extensions[$ext].Count++
$extensions[$ext].Size += $info.Length
}

$sw.Stop()

$top = $extensions.GetEnumerator() |
Sort-Object { $_.Value.Size } -Descending |
Select-Object -First 10

Write-Host "扫描完成:$totalCount 个文件,$([math]::Round($totalSize / 1MB, 2)) MB" -ForegroundColor Green
Write-Host "耗时:$($sw.ElapsedMilliseconds) ms" -ForegroundColor Cyan

foreach ($entry in $top) {
[PSCustomObject]@{
Extension = $entry.Key
Count = $entry.Value.Count
SizeMB = [math]::Round($entry.Value.Size / 1MB, 2)
}
}
}

# 全量扫描
Get-LargeFileStats -Path "C:\Projects" | Format-Table -AutoSize

# 采样扫描(每 10 个文件取 1 个,速度更快)
# Get-LargeFileStats -Path "C:\Projects" -SampleRate 10

执行结果示例:

1
2
3
4
5
6
7
8
扫描完成:45678 个文件,2345.67 MB
耗时:3456 ms
Extension Count SizeMB
--------- ----- ------
.cs 12345 890.12
.dll 2345 456.78
.json 3456 234.56
.csproj 456 12.34

批量处理与并行优化

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
# PowerShell 7 的 ForEach-Object -Parallel
$urls = @(
"https://httpbin.org/delay/1"
"https://httpbin.org/delay/1"
"https://httpbin.org/delay/1"
"https://httpbin.org/delay/1"
"https://httpbin.org/delay/1"
)

# 顺序处理
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results1 = $urls | ForEach-Object {
$resp = Invoke-WebRequest $_ -UseBasicParsing -TimeoutSec 10
$resp.StatusCode
}
$sw.Stop()
Write-Host "顺序请求 5 个 URL:$($sw.ElapsedMilliseconds) ms"

# 并行处理(PowerShell 7+)
$sw.Restart()
$results2 = $urls | ForEach-Object -ThrottleLimit 5 -Parallel {
$resp = Invoke-WebRequest $_ -UseBasicParsing -TimeoutSec 10
$resp.StatusCode
}
$sw.Stop()
Write-Host "并行请求 5 个 URL(5 并发):$($sw.ElapsedMilliseconds) ms"

# Runspace 池(PowerShell 5.1 兼容的并行方案)
function Invoke-Parallel {
param(
[Parameter(Mandatory)][scriptblock]$ScriptBlock,
[Parameter(Mandatory)][object[]]$InputObject,
[int]$ThrottleLimit = 4
)

$runspacePool = [runspacefactory]::CreateRunspacePool(1, $ThrottleLimit)
$runspacePool.Open()

$jobs = foreach ($item in $InputObject) {
$powershell = [powershell]::Create().AddScript($ScriptBlock).AddArgument($item)
$powershell.RunspacePool = $runspacePool
@{
PowerShell = $powershell
Handle = $powershell.BeginInvoke()
}
}

$results = foreach ($job in $jobs) {
$job.PowerShell.EndInvoke($job.Handle)
$job.PowerShell.Dispose()
}

$runspacePool.Close()
$runspacePool.Dispose()
return $results
}

# 使用 Runspace 池并行处理
$numbers = 1..20
$squares = Invoke-Parallel -InputObject $numbers -ThrottleLimit 4 -ScriptBlock {
Start-Sleep -Milliseconds 100
$_ * $_
}
Write-Host "Runspace 并行计算完成:$($squares.Count) 个结果"

执行结果示例:

1
2
3
顺序请求 5 个 URL:5234 ms
并行请求 5 个 URL(5 并发):1102 ms
Runspace 并行计算完成:20 个结果

注意事项

  1. 避免过早优化:管道代码更易读易维护,只有在确实遇到性能问题时才需要优化
  2. 测量优先:使用 Measure-Command[Stopwatch] 测量后再决定优化方向
  3. 内存权衡:数组赋值 ($result = foreach {...}) 会将所有结果存入内存,大数据集注意内存压力
  4. 并行开销ForEach-Object -Parallel 有 runspace 创建开销,小任务量时可能更慢
  5. LINQ 限制:需要精确的类型转换,类型不匹配时会报错,调试成本较高
  6. GC 压力:大量临时对象会增加垃圾回收压力,适时调用 [GC]::Collect() 释放内存

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: 传递

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本

持续集成/持续部署(CI/CD)是现代 DevOps 的核心实践。PowerShell 作为 Windows 生态的首选脚本语言,天然适配 Azure DevOps、GitHub Actions、Jenkins 等 CI/CD 平台。通过编写结构化的部署脚本,可以将应用发布流程标准化、可重复、可审计。

本文将讲解如何编写适配 CI/CD 的 PowerShell 部署脚本、多环境配置管理,以及 GitHub Actions 的集成示例。

CI/CD 脚本设计原则

好的 CI/CD 脚本应遵循以下原则:幂等性(多次执行结果相同)、参数化(通过参数控制行为)、结构化输出(便于日志解析)和错误处理(失败时明确报告原因):

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
# 标准 CI/CD 脚本模板
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('dev', 'staging', 'production')]
[string]$Environment,

[string]$Version,
[string]$ConfigPath = "./config",
[switch]$DryRun
)

$ErrorActionPreference = 'Stop'

function Write-Step {
param([string]$Message)
Write-Host "`n========== $Message ==========" -ForegroundColor Cyan
}

function Write-Success {
param([string]$Message)
Write-Host " OK: $Message" -ForegroundColor Green
}

function Write-Fail {
param([string]$Message)
Write-Host " FAIL: $Message" -ForegroundColor Red
}

# 加载环境配置
Write-Step "加载配置:$Environment"
$configFile = Join-Path $ConfigPath "$Environment.json"
if (-not (Test-Path $configFile)) {
Write-Fail "配置文件不存在:$configFile"
exit 1
}
$config = Get-Content $configFile | ConvertFrom-Json
Write-Success "配置已加载"

# 构建步骤
Write-Step "构建应用"
$buildOutput = Join-Path $PWD "dist"
if (Test-Path $buildOutput) { Remove-Item $buildOutput -Recurse -Force }

if (-not $DryRun) {
# 实际构建逻辑
dotnet publish -c Release -o $buildOutput
if ($LASTEXITCODE -ne 0) {
Write-Fail "构建失败"
exit 1
}
}
Write-Success "构建完成"

Write-Host "`n部署就绪:$Environment @ $Version" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
========== 加载配置:production ==========
OK: 配置已加载

========== 构建应用 ==========
OK: 构建完成

部署就绪:production @ 2.5.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
39
40
41
42
43
44
45
46
47
# 环境配置文件结构
$configs = @{
dev = @{
AppName = "myapp-dev"
Server = "dev-server-01"
Port = 8080
Debug = $true
LogLevel = "Debug"
Database = "Server=dev-db;Database=myapp_dev;"
}
staging = @{
AppName = "myapp-staging"
Server = "staging-server-01"
Port = 80
Debug = $false
LogLevel = "Information"
Database = "Server=staging-db;Database=myapp_staging;"
}
production = @{
AppName = "myapp"
Server = "prod-server-01"
Port = 80
Debug = $false
LogLevel = "Warning"
Database = "Server=prod-db;Database=myapp_prod;"
}
}

# 生成配置文件
foreach ($env in $configs.Keys) {
$path = "config/$env.json"
$configs[$env] | ConvertTo-Json -Depth 5 | Set-Content $path
Write-Host "已生成:$path" -ForegroundColor Green
}

# 安全配置替换(从环境变量读取敏感信息)
function Get-SecureConfig {
param([string]$Environment)

$config = Get-Content "config/$Environment.json" | ConvertFrom-Json

# 从环境变量替换敏感配置
$config.Database = $config.Database -replace 'Database=',
"User ID=$($env:DB_USER);Password=$($env:DB_PASSWORD);Database="

return $config
}

执行结果示例:

1
2
3
已生成:config/dev.json
已生成:config/staging.json
已生成:config/production.json

GitHub Actions 集成

以下是一个完整的 GitHub Actions 工作流,使用 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 生成 GitHub Actions 工作流文件
$workflow = @'
name: Deploy Application

on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'
type: choice
options:
- dev
- staging
- production

jobs:
deploy:
runs-on: windows-latest
environment: ${{ github.event.inputs.environment || 'staging' }}

steps:
- uses: actions/checkout@v4

- name: Run deployment script
shell: pwsh
env:
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
./scripts/deploy.ps1 `
-Environment "${{ github.event.inputs.environment || 'staging' }}" `
-Version "${{ github.sha }}"

- name: Health check
shell: pwsh
run: |
$env_name = "${{ github.event.inputs.environment || 'staging' }}"
$config = Get-Content "config/$env_name.json" | ConvertFrom-Json
$url = "http://$($config.Server):$($config.Port)/health"

try {
$response = Invoke-RestMethod -Uri $url -TimeoutSec 30
Write-Host "Health check passed: $($response.status)"
} catch {
Write-Error "Health check failed: $($_.Exception.Message)"
exit 1
}
'@

$workflowDir = ".github/workflows"
New-Item -Path $workflowDir -ItemType Directory -Force | Out-Null
Set-Content -Path "$workflowDir/deploy.yml" -Value $workflow
Write-Host "GitHub Actions 工作流已创建" -ForegroundColor Green

执行结果示例:

1
GitHub Actions 工作流已创建

部署回滚脚本

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
function Invoke-Rollback {
<#
.SYNOPSIS
回滚到指定版本
#>
param(
[Parameter(Mandatory)]
[string]$Environment,

[string]$TargetVersion,

[int]$KeepReleases = 5
)

$releaseDir = "C:\Releases\$Environment"
$currentLink = Join-Path $releaseDir "current"

# 获取当前版本
$currentVersion = (Get-Item $currentLink -ErrorAction SilentlyContinue).Target
Write-Host "当前版本:$currentVersion" -ForegroundColor Cyan

# 列出可用版本
$releases = Get-ChildItem $releaseDir -Directory |
Where-Object { $_.Name -match '^\d{8}-\d{6}-v' } |
Sort-Object Name -Descending

Write-Host "可用版本:" -ForegroundColor Yellow
$releases | Select-Object -First 10 Name |
Format-Table -AutoSize

if ($TargetVersion) {
$target = $releases | Where-Object { $_.Name -like "*$TargetVersion*" }
} else {
$target = $releases[1] # 上一个版本
}

if (-not $target) {
Write-Error "未找到目标版本"
return
}

Write-Host "回滚到:$($target.Name)" -ForegroundColor Yellow

# 更新符号链接
if (Test-Path $currentLink) { Remove-Item $currentLink }
New-Item -ItemType SymbolicLink -Path $currentLink -Target $target.FullName | Out-Null

# 重启应用
Restart-Service "MyApp-$Environment"
Write-Host "回滚完成" -ForegroundColor Green

# 清理旧版本
$toDelete = $releases | Select-Object -Skip $KeepReleases
foreach ($old in $toDelete) {
Remove-Item $old.FullName -Recurse -Force
Write-Host "已清理:$($old.Name)" -ForegroundColor DarkGray
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
当前版本:20250603-080000-v2.5.0
可用版本:
Name
----
20250603-080000-v2.5.0
20250602-080000-v2.4.0
20250601-080000-v2.3.0

回滚到:20250602-080000-v2.4.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
function Send-DeploymentNotification {
param(
[string]$Environment,
[string]$Version,
[string]$Status,
[string]$WebhookUrl
)

$color = switch ($Status) {
'success' { '#36a64f' }
'failed' { '#ff0000' }
'rollback' { '#ff9800' }
default { '#808080' }
}

$body = @{
attachments = @(
@{
color = $color
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = "*部署${Status}*`n环境:$Environment`n版本:$Version`n操作人:$env:USER`n时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
}
}
)
}
)
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $body -ContentType 'application/json'
Write-Host "通知已发送" -ForegroundColor Green
}

# 成功通知
Send-DeploymentNotification -Environment "production" -Version "2.5.0" -Status "success" -WebhookUrl $env:SLACK_WEBHOOK

执行结果示例:

1
通知已发送

注意事项

  1. 幂等性:部署脚本必须幂等,重复执行不应导致错误或数据不一致
  2. 蓝绿部署:生产环境建议使用蓝绿部署或金丝雀发布策略,降低部署风险
  3. 密钥管理:敏感配置应使用 CI/CD 平台的密钥管理(GitHub Secrets、Azure Key Vault),不要提交到代码仓库
  4. 回滚策略:始终保留最近 N 个版本的发布包,确保可以快速回滚
  5. 健康检查:部署后自动执行健康检查,确认服务正常运行后再标记部署完成
  6. 审计日志:所有部署操作应记录审计日志,包括时间、操作人、版本号和结果

PowerShell 技能连载 - PowerShell 7 新特性深度实践

适用于 PowerShell 7.0 及以上版本

PowerShell 7 是 PowerShell 团队基于 .NET Core(现 .NET 5+)重新构建的重大版本。与 Windows PowerShell 5.1 相比,PS7 不仅实现了跨平台运行(Windows、Linux、macOS),还引入了大量新语法特性和性能优化。对于仍在使用 PS5.1 的运维团队来说,了解这些新特性可以显著提升脚本编写效率和代码可读性。

本文将深入讲解 PowerShell 7 中最实用的几项新特性,包括三元运算符、空合并运算符、管道链运算符、以及跨平台兼容性实践,并在文末提供一份从 PS5.1 迁移到 PS7 的检查清单。

三元运算符

PowerShell 7 终于引入了三元运算符 ? :,这是 C 系语言开发者期待已久的特性。它可以在一行内完成条件判断与赋值,让代码更加简洁。

在 PS5.1 中,我们必须使用 if-else 语句来完成简单的条件赋值:

1
2
3
4
5
6
7
8
# PS5.1 风格:冗长的条件赋值
$cpuLoad = 72
if ($cpuLoad -gt 80) {
$status = "危险"
} else {
$status = "正常"
}
Write-Host "CPU 状态:$status"
1
CPU 状态:正常

在 PowerShell 7 中,同样的逻辑可以用三元运算符一行搞定:

1
2
3
4
# PS7 风格:简洁的三元运算符
$cpuLoad = 72
$status = $cpuLoad -gt 80 ? "危险" : "正常"
Write-Host "CPU 状态:$status"
1
CPU 状态:正常

三元运算符的语法为 条件 ? 真值 : 假值。条件表达式必须放在 ? 的左侧,求值结果会被自动转换为布尔值。这对于将复杂的 if-else 嵌套扁平化非常有帮助。

在实际运维场景中,三元运算符特别适合用在配置化和状态判断的场景。来看一个更贴近实战的例子,批量检查服务状态并生成报告:

1
2
3
4
5
6
7
8
9
10
11
12
# 批量服务状态检查
$services = @("Spooler", "wuauserv", "WinRM", "BITS")
$report = $services | ForEach-Object {
$svc = Get-Service -Name $_ -ErrorAction SilentlyContinue
$svcExists = $null -ne $svc
[PSCustomObject]@{
服务名称 = $_
状态 = $svcExists ? $svc.Status.ToString() : "未找到"
运行中 = ($svcExists -and $svc.Status -eq 'Running') ? "是" : "否"
}
}
$report | Format-Table -AutoSize
1
2
3
4
5
6
服务名称 状态    运行中
-------- ---- ------
Spooler Running 是
wuauserv Stopped 否
WinRM Running 是
BITS Running 是

这里我们用三元运算符避免了多层 if-else 嵌套,让代码更直观。注意三元运算符的优先级低于比较运算符,所以 $cpuLoad -gt 80 ? "危险" : "正常" 不需要额外加括号。

三元运算符嵌套使用

三元运算符可以嵌套,但建议不要超过两层,否则会严重影响可读性:

1
2
3
4
# 嵌套三元运算符:磁盘空间告警级别
$freePercent = 12
$level = $freePercent -gt 30 ? "正常" : ($freePercent -gt 15 ? "警告" : "严重")
Write-Host "磁盘告警级别:$level(剩余 ${freePercent}%)"
1
磁盘告警级别:严重(剩余 12%)

空合并运算符 ?? 和空条件赋值 ??=

空合并运算符 ?? 和空条件赋值 ??= 是 PowerShell 7 从 C# 借鉴的另一组实用特性。它们专门用于处理 $null 值,让默认值赋值变得极其简洁。

空合并运算符 ??

?? 运算符的含义是:如果左侧不为 $null,返回左侧值;否则返回右侧值。

1
2
3
4
5
6
7
8
# 传统写法 vs ?? 写法
$configLogLevel = $null
$logLevel = $configLogLevel ?? "INFO"
Write-Host "日志级别:$logLevel"

$serverName = "prod-web-01"
$name = $serverName ?? "localhost"
Write-Host "服务器:$name"
1
2
日志级别:INFO
服务器:prod-web-01

这在处理配置项时特别有用。当用户未提供某个配置值时,可以优雅地回退到默认值。

来看一个实际的配置加载场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 模拟从不同来源加载配置
$envConfig = @{
Port = $null
MaxThreads = $null
LogPath = $null
Timeout = 30
}

# 使用 ?? 为每个配置项设置默认值
$port = $envConfig.Port ?? 8080
$maxThreads = $envConfig.MaxThreads ?? 4
$logPath = $envConfig.LogPath ?? "/var/log/app.log"
$timeout = $envConfig.Timeout ?? 60

Write-Host "最终配置:"
Write-Host " 端口:$port"
Write-Host " 最大线程:$maxThreads"
Write-Host " 日志路径:$logPath"
Write-Host " 超时(秒):$timeout"
1
2
3
4
5
最终配置:
端口:8080
最大线程:4
日志路径:/var/log/app.log
超时(秒):30

注意 $timeout 的值是 30 而非默认值 60,因为 $envConfig.Timeout 不为 $null?? 只在左侧为 $null 时才取右侧值。

空条件赋值 ??=

??= 运算符在变量为 $null 时才执行赋值操作。这在初始化配置或缓存场景中非常方便:

1
2
3
4
5
6
7
8
9
10
11
12
# 使用 ??= 确保变量只被初始化一次
$script:connectionPool ??= @()

# 配置字典中不存在则设置默认值
$settings = @{
Theme = "Dark"
}
$settings["Theme"] ??= "Light"
$settings["Language"] ??= "zh-CN"

Write-Host "主题:$($settings['Theme'])"
Write-Host "语言:$($settings['Language'])"
1
2
主题:Dark
语言:zh-CN

Theme 保持了原值 “Dark”,因为 $settings["Theme"] 已存在且不为 $null;而 Language 是新键,被 ??= 设置为默认值 “zh-CN”。

?? 和 ??= 的链式使用

?? 支持链式操作,可以从多个来源依次取值,直到找到非 $null 的值:

1
2
3
4
5
6
7
# 多级配置回退:命令行参数 > 环境变量 > 配置文件 > 硬编码默认值
$cmdLinePort = $null
$envPort = $null
$filePort = 9090

$finalPort = $cmdLinePort ?? $envPort ?? $filePort ?? 8080
Write-Host "使用端口:$finalPort"
1
使用端口:9090

管道链运算符 && 和 ||

管道链运算符 &&|| 是 PowerShell 7 从 Unix shell 借鉴的特性,可以根据前一个命令的执行成功与否来决定是否执行下一个命令。

  • &&:前一个命令成功($?$true)时,才执行后一个命令
  • ||:前一个命令失败时,才执行后一个命令

基本用法

1
2
3
4
5
6
7
8
# && 用法:目录存在才执行清理
$targetPath = "/tmp/workdir"
$result = (Test-Path $targetPath) && (Get-ChildItem $targetPath | Remove-Item -Recurse -Force)
if ($result) {
Write-Host "清理完成:$targetPath"
} else {
Write-Host "目录不存在,跳过清理"
}
1
目录不存在,跳过清理
1
2
3
4
# || 用法:创建目录失败时使用备用路径
$newDir = "/opt/app/data"
New-Item -ItemType Directory -Path $newDir -Force -ErrorAction SilentlyContinue ||
Write-Warning "无法创建 $newDir,尝试备用路径"

构建与部署流水线

管道链运算符最典型的场景是 CI/CD 流水线脚本。在 PS5.1 中需要大量 if ($LASTEXITCODE -ne 0) 检查,现在可以大幅简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# PS5.1 风格:繁琐的错误检查
Write-Host "=== 构建流水线(PS5.1 风格)==="

npm run lint
if ($LASTEXITCODE -ne 0) {
Write-Error "Lint 失败,中止构建"
exit 1
}

npm run build
if ($LASTEXITCODE -ne 0) {
Write-Error "构建失败,中止部署"
exit 1
}

npm run test
if ($LASTEXITCODE -ne 0) {
Write-Error "测试失败,中止部署"
exit 1
}

Write-Host "所有步骤通过!"
1
2
3
4
5
6
7
8
# PS7 风格:简洁的管道链
Write-Host "=== 构建流水线(PS7 风格)==="

npm run lint &&
npm run build &&
npm run test &&
Write-Host "所有步骤通过!" ||
Write-Error "流水线执行失败"

这两段代码功能完全相同,但 PS7 的写法更加简洁直观。&& 确保每一步成功后才继续,|| 捕获任何失败并输出错误信息。

复合使用:带回退的服务重启

1
2
3
4
5
6
7
8
# 尝试优雅重启,失败则强制终止再启动
$svcName = "nginx"
Restart-Service -Name $svcName -ErrorAction SilentlyContinue ||
(Stop-Process -Name $svcName -Force -ErrorAction SilentlyContinue;
Start-Sleep -Seconds 2;
Start-Service -Name $svcName) &&
Write-Host "$svcName 已成功重启" ||
Write-Warning "$svcName 重启失败,请手动检查"
1
nginx 已成功重启

注意事项

管道链运算符基于命令的成功状态($? 自动变量)来判断,而不是 $LASTEXITCODE。对于外部程序(如 npmgit),PowerShell 会将非零退出码映射为失败状态。但对于 PowerShell cmdlet,即使使用了 -ErrorAction SilentlyContinue,如果 cmdlet 内部产生了错误,$? 仍然会变成 $false

1
2
3
4
5
6
# 验证 $? 的行为
Get-Item "/nonexistent/path" -ErrorAction SilentlyContinue
Write-Host "上一个命令成功:$?"

Test-Path "/nonexistent/path"
Write-Host "上一个命令成功:$?"
1
2
上一个命令成功:False
上一个命令成功:True

Get-Item 在路径不存在时会产生错误,$? 变为 $false;而 Test-Path 只是返回布尔结果,不会产生错误,$? 仍然是 $true

跨平台兼容性实践

PowerShell 7 最大的架构改变是跨平台支持。同一套 PowerShell 脚本可以在 Windows、Linux 和 macOS 上运行。但要真正实现”一次编写,到处运行”,需要注意以下几个关键点。

自动变量 $IsWindows、$IsLinux、$IsMacOS

PS7 引入了三个布尔类型的自动变量,用于在运行时判断当前操作系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 跨平台路径处理函数
function Get-AppDataPath {
$appName = "MyApp"

if ($IsWindows) {
$basePath = [System.Environment]::GetFolderPath('ApplicationData')
} elseif ($IsLinux -or $IsMacOS) {
$basePath = Join-Path $env:HOME ".config"
}

$appPath = Join-Path $basePath $appName

if (-not (Test-Path $appPath)) {
New-Item -ItemType Directory -Path $appPath -Force | Out-Null
}

return $appPath
}

$path = Get-AppDataPath
Write-Host "应用数据路径:$path"
1
应用数据路径:/Users/wubo/.config/MyApp

在 PS5.1 中,判断操作系统通常依赖 Get-WmiObject 或环境变量,方式不统一且容易出错。PS7 的三个自动变量让平台判断变得清晰明了。

跨平台路径处理

路径分隔符是跨平台脚本最常见的坑。Windows 使用反斜杠 \,Linux/macOS 使用正斜杠 /。PowerShell 7 的 Join-PathSplit-Path 会自动处理平台差异:

1
2
3
4
5
6
7
8
# 始终使用 Join-Path 拼接路径,而非手动拼接
$projectRoot = $PWD.Path
$configDir = Join-Path $projectRoot "config"
$logFile = Join-Path $configDir "app.log"

Write-Host "项目根目录:$projectRoot"
Write-Host "配置目录:$configDir"
Write-Host "日志文件:$logFile"
1
2
3
项目根目录:/Users/wubo/Code/home.vichamp.com
配置目录:/Users/wubo/Code/home.vichamp.com/config
日志文件:/Users/wubo/Code/home.vichamp.com/config/app.log

避免 Windows 专属的 cmdlet

某些 cmdlet 只在 Windows 上可用(如 Get-WmiObjectGet-EventLogSet-Service 的某些参数)。PS7 提供了跨平台的替代方案:

PS5.1 (Windows Only) PS7 (跨平台替代)
Get-WmiObject Get-CimInstance
Get-EventLog Get-WinEvent (Windows) 或 journalctl (Linux)
netstat Get-NetTCPConnection
手动操作注册表 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
# 跨平台获取系统信息
function Get-SystemInfo {
$info = [PSCustomObject]@{
OS = "未知"
Version = "未知"
MachineName = $env:COMPUTERNAME ?? $env:HOSTNAME ?? "未知"
PowerShellVer = $PSVersionTable.PSVersion.ToString()
Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
}

if ($IsWindows) {
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue
if ($osInfo) {
$info.OS = "Windows"
$info.Version = $osInfo.Version
}
} elseif ($IsLinux) {
$info.OS = "Linux"
if (Test-Path /etc/os-release) {
$content = Get-Content /etc/os-release
$prettyName = $content | Where-Object { $_ -match '^PRETTY_NAME=' }
if ($prettyName) {
$info.Version = ($prettyName -replace '^PRETTY_NAME="?', '' -replace '"?$', '')
}
}
} elseif ($IsMacOS) {
$info.OS = "macOS"
$info.Version = (sw_vers -productVersion 2>$null) ?? "未知"
}

return $info
}

Get-SystemInfo | Format-List
1
2
3
4
5
OS            : macOS
Version : 15.4
MachineName : home.vichamp.com
PowerShellVer : 7.4.6
Architecture : Arm64

实战:迁移 PS5.1 脚本到 PS7 的检查清单

将现有 PS5.1 脚本迁移到 PowerShell 7 时,需要系统性地检查以下方面。这里提供一份实用的迁移检查函数:

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
function Test-PS7Compatibility {
param(
[Parameter(Mandatory)]
[string]$ScriptPath
)

if (-not (Test-Path $ScriptPath)) {
Write-Warning "文件不存在:$ScriptPath"
return
}

$content = Get-Content $ScriptPath -Raw
$findings = [System.Collections.Generic.List[string]]::new()

# 检查项 1:是否使用了已弃用的 WMI cmdlet
if ($content -match 'Get-WmiObject|Set-WmiInstance|Remove-WmiObject|Invoke-WmiMethod') {
$findings.Add("[弃用] 发现 WMI cmdlet,建议替换为 CimInstance 系列")
}

# 检查项 2:是否使用了 Windows 专属的 EventLog
if ($content -match 'Get-EventLog') {
$findings.Add("[弃用] 发现 Get-EventLog,建议替换为 Get-WinEvent")
}

# 检查项 3:是否硬编码了 Windows 路径
if ($content -match '[A-Z]:\\' -and -not ($content -match 'IsWindows|env:OS|IsLinux')) {
$findings.Add("[兼容性] 发现硬编码的 Windows 路径,建议使用 Join-Path")
}

# 检查项 4:是否使用了 .NET Framework 专属类型
if ($content -match 'System\.Drawing|System\.Windows\.Forms|System\.DirectoryServices') {
$findings.Add("[兼容性] 发现 .NET Framework 专属类型,Linux/macOS 上不可用")
}

# 检查项 5:是否可以简化为 PS7 新语法
if ($content -match 'if\s*\(.+\)\s*\{\s*\$\w+\s*=\s*.+\}\s*else\s*\{\s*\$\w+\s*=\s*.+\}') {
$findings.Add("[优化] 发现可使用三元运算符简化的 if-else 赋值")
}

$result = [PSCustomObject]@{
文件 = (Split-Path $ScriptPath -Leaf)
检查项数 = $findings.Count
发现 = $findings
}

return $result
}

使用这个函数可以快速扫描脚本的兼容性问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 假设有一个待迁移的脚本
$scriptContent = @(
'$svc = Get-WmiObject -Class Win32_Service -Filter "Name=''Spooler''"'
'$log = Get-EventLog -LogName Application -Newest 10'
'$path = "C:\Program Files\MyApp\config.xml"'
'if ($env:DEBUG) { $level = "DEBUG" } else { $level = "INFO" }'
) -join "`n"

$tempScript = Join-Path $env:TEMP "legacy-script.ps1"
Set-Content -Path $tempScript -Value $scriptContent

$report = Test-PS7Compatibility -ScriptPath $tempScript
$report | Format-List

Write-Host "`n--- 详细发现 ---"
$report.发现 | ForEach-Object { Write-Host " - $_" }
1
2
3
4
5
6
7
8
9
10
11
12
文件     : legacy-script.ps1
检查项数 : 4
发现 : {[弃用] 发现 WMI cmdlet,建议替换为 CimInstance 系列,
[弃用] 发现 Get-EventLog,建议替换为 Get-WinEvent,
[兼容性] 发现硬编码的 Windows 路径,建议使用 Join-Path,
[优化] 发现可使用三元运算符简化的 if-else 赋值}

--- 详细发现 ---
- [弃用] 发现 WMI cmdlet,建议替换为 CimInstance 系列
- [弃用] 发现 Get-EventLog,建议替换为 Get-WinEvent
- [兼容性] 发现硬编码的 Windows 路径,建议使用 Join-Path
- [优化] 发现可使用三元运算符简化的 if-else 赋值

迁移检查清单速查表

除了自动化检查外,以下是手动迁移时建议逐项确认的清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 迁移清单:可保存为脚本运行
$checklist = @(
[PSCustomObject]@{ 序号 = 1; 类别 = "语法"; 检查项 = "将 if-else 条件赋值替换为三元运算符 ? :"; 优先级 = "建议" }
[PSCustomObject]@{ 序号 = 2; 类别 = "语法"; 检查项 = "将 `$null 检查替换为 ?? 和 ??="; 优先级 = "建议" }
[PSCustomObject]@{ 序号 = 3; 类别 = "语法"; 检查项 = "将 if (`$LASTEXITCODE) 替换为 && 和 ||"; 优先级 = "建议" }
[PSCustomObject]@{ 序号 = 4; 类别 = "弃用"; 检查项 = "Get-WmiObject 替换为 Get-CimInstance"; 优先级 = "必须" }
[PSCustomObject]@{ 序号 = 5; 类别 = "弃用"; 检查项 = "Get-EventLog 替换为 Get-WinEvent"; 优先级 = "必须" }
[PSCustomObject]@{ 序号 = 6; 类别 = "兼容性"; 检查项 = "硬编码路径改为 Join-Path 拼接"; 优先级 = "高" }
[PSCustomObject]@{ 序号 = 7; 类别 = "兼容性"; 检查项 = "移除 Windows 专属 .NET 类型依赖"; 优先级 = "高" }
[PSCustomObject]@{ 序号 = 8; 类别 = "兼容性"; 检查项 = "添加平台判断逻辑 (`$IsWindows/`$IsLinux/`$IsMacOS)"; 优先级 = "高" }
[PSCustomObject]@{ 序号 = 9; 类别 = "兼容性"; 检查项 = "编码统一为 UTF-8 (带 BOM)"; 优先级 = "建议" }
[PSCustomObject]@{ 序号 = 10; 类别 = "测试"; 检查项 = "在 Windows 和 Linux 上分别运行测试"; 优先级 = "必须" }
[PSCustomObject]@{ 序号 = 11; 类别 = "测试"; 检查项 = "使用 Pester 编写跨平台单元测试"; 优先级 = "建议" }
)

$checklist | Format-Table -AutoSize
1
2
3
4
5
6
7
8
9
10
11
12
13
序号 类别   检查项                                                                优先级
---- ---- ------ ------
1 语法 将 if-else 条件赋值替换为三元运算符 ? : 建议
2 语法 将 $null 检查替换为 ?? 和 ??= 建议
3 语法 将 if ($LASTEXITCODE) 替换为 && 和 || 建议
4 弃用 Get-WmiObject 替换为 Get-CimInstance 必须
5 弃用 Get-EventLog 替换为 Get-WinEvent 必须
6 兼容性 硬编码路径改为 Join-Path 拼接 高
7 兼容性 移除 Windows 专属 .NET 类型依赖 高
8 兼容性 添加平台判断逻辑 ($IsWindows/$IsLinux/$IsMacOS) 高
9 兼容性 编码统一为 UTF-8 (带 BOM) 建议
10 测试 在 Windows 和 Linux 上分别运行测试 必须
11 测试 使用 Pester 编写跨平台单元测试 建议

使用要点与常见坑点

  • PowerShell 7 与 Windows PowerShell 5.1 是并行安装的关系,不会覆盖 PS5.1。PS7 的可执行文件是 pwsh(而非 powershell.exe),两者可以共存
  • 三元运算符 ? : 中的 ? 与 PowerShell 的 ? 别名(Where-Object)是不同的语法特性,注意区分上下文
  • ?? 运算符只检查 $null,不会检查空字符串或空数组。如果需要检查空字符串,请用三元运算符:($str ? $str : "默认值")
  • 管道链运算符 &&|| 基于 $? 自动变量判断成功状态,对 PowerShell cmdlet 和外部程序的判断标准不同
  • 跨平台脚本应始终使用 Join-Path 代替手动拼接路径,使用 PSCustomObject 代替 Windows 专属 .NET 类型
  • 迁移脚本时,建议先在 PS7 中运行 Invoke-ScriptAnalyzer 检查兼容性警告,再逐步修正

PowerShell 技能连载 - 理解对象模型

适用于 PowerShell 所有版本

PowerShell 与传统 Shell(如 Bash、CMD)最大的区别在于:PowerShell 处理的是 .NET 对象,而不是纯文本。理解”一切皆对象”这个核心理念,是从入门走向精通 PowerShell 的关键一步。

在 Bash 中,命令输出是一串字符串,你需要用 awksedgrep 等工具来解析。而在 PowerShell 中,每条命令的输出都是结构化的对象,拥有属性和方法,可以直接访问和操作。这让自动化脚本更加健壮、可读且易于维护。

一切皆对象

让我们从一个简单的例子开始。获取正在运行的进程,看起来像表格,但实际上每个行都是一个 .NET 对象。

1
2
3
4
5
# 获取进程信息
$process = Get-Process -Name "powershell" | Select-Object -First 1

# 查看 $process 的类型
$process.GetType().FullName
1
System.Diagnostics.Process

这个 $process 变量不是一个字符串,而是一个完整的 System.Diagnostics.Process 对象,它拥有丰富的属性和方法。

1
2
3
4
5
# 直接访问对象的属性
Write-Host "进程 ID:$($process.Id)"
Write-Host "内存占用:$([math]::Round($process.WorkingSet64 / 1MB, 2)) MB"
Write-Host "启动时间:$($process.StartTime)"
Write-Host "CPU 时间:$($process.TotalProcessorTime)"
1
2
3
4
进程 ID:12345
内存占用:85.32 MB
启动时间:2025/4/10 8:30:15
CPU 时间:00:00:03.4567890

Get-Member 探索对象

Get-Member 是 PowerShell 中最重要的工具之一。当你拿到一个对象却不知道它有哪些属性和方法时,Get-Member 就是你的”说明书”。

1
2
# 查看 Get-Date 返回对象的所有成员
Get-Date | Get-Member -MemberType Properties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   TypeName: System.DateTime

Name MemberType Definition
---- ---------- ----------
Date Property datetime Date {get;}
Day Property int Day {get;}
DayOfWeek Property System.DayOfWeek DayOfWeek {get;}
DayOfYear Property int DayOfYear {get;}
Hour Property int Hour {get;}
Kind Property System.DateTimeKind Kind {get;}
Millisecond Property int Millisecond {get;}
Minute Property int Minute {get;}
Month Property int Month {get;}
Second Property int Second {get;}
Ticks Property long Ticks {get;}
TimeOfDay Property timespan TimeOfDay {get;}
Year Property int Year {get;}

可以按类型筛选成员,常用的 MemberType 包括:Property(属性)、Method(方法)、Event(事件)等。

1
2
# 查看字符串对象的所有方法
"Hello, PowerShell" | Get-Member -MemberType Method
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   TypeName: System.String

Name MemberType Definition
---- ---------- ----------
Clone Method System.Object Clone()
CompareTo Method int CompareTo(string value), int CompareTo(obje...
Contains Method bool Contains(string value), bool Contains(stri...
EndsWith Method bool EndsWith(string value), bool EndsWith(stri...
Equals Method bool Equals(string value), bool Equals(object o...
IndexOf Method int IndexOf(string value), int IndexOf(char val...
Insert Method string Insert(int startIndex, string value)
PadLeft Method string PadLeft(int totalWidth), string PadLeft(...
Remove Method string Remove(int startIndex, int count), strin...
Replace Method string Replace(string oldValue, string newValue)...
Split Method string[] Split(string[] separator), string[] Sp...
Substring Method string Substring(int startIndex), string Substr...
ToLower Method string ToLower(), string ToLower(cultureInfo cu...
ToUpper Method string ToUpper(), string ToUpper(cultureInfo cu...
Trim Method string Trim(), string Trim(char[] trimChars)

类型系统与类型适配

PowerShell 的类型系统建立在 .NET 之上,但做了许多扩展和适配。

基本类型

1
2
3
4
5
6
7
8
9
10
11
12
# PowerShell 中的常见类型
$int = 42 # System.Int32
$double = 3.14 # System.Double
$string = "Hello" # System.String
$bool = $true # System.Boolean
$array = @(1, 2, 3) # System.Object[]
$hashtable = @{ Name = "Test" } # System.Collections.Hashtable
$null = $null # null

# 查看每个变量的类型
@($int, $double, $string, $bool, $array, $hashtable) |
ForEach-Object { $_.GetType().FullName }
1
2
3
4
5
6
System.Int32
System.Double
System.String
System.Boolean
System.Object[]
System.Collections.Hashtable

类型转换

PowerShell 会自动进行很多类型转换,但有时需要手动转换。

1
2
3
4
5
6
7
8
9
10
# 显式类型转换
[string]$number = 42
[int]$text = "123"
[datetime]$dateStr = "2025-04-10"
[xml]$xmlStr = '<root><item>test</item></root>'

Write-Host "字符串数字:'$number',类型:$($number.GetType().Name)"
Write-Host "文本转整数:$text,类型:$($text.GetType().Name)"
Write-Host "日期字符串:$dateStr,类型:$($dateStr.GetType().Name)"
Write-Host "XML 根节点:$($xmlStr.root.item)"
1
2
3
4
字符串数字:'42',类型:String
文本转整数:123,类型:Int32
日期字符串:2025/4/10 0:00:00,类型:DateTime
XML 根节点:test

属性和方法的使用

对象不仅有属性(数据),还有方法(行为)。掌握属性和方法的调用是高效使用 PowerShell 的基础。

1
2
3
4
5
6
7
8
9
10
11
12
# 操作 DateTime 对象
$now = Get-Date

# 属性访问
Write-Host "当前年份:$($now.Year)"
Write-Host "当前月份:$($now.Month)"
Write-Host "是否闰年:$([datetime]::IsLeapYear($now.Year))"

# 方法调用
Write-Host "明天此时:$($now.AddDays(1))"
Write-Host "上月今天:$($now.AddMonths(-1))"
Write-Host "格式化输出:$($now.ToString('yyyy年MM月dd日 HH:mm:ss'))"
1
2
3
4
5
6
当前年份:2025
当前月份:4
是否闰年:False
明天此时:2025/4/11 9:30:15
上月今天:2025/3/10 9:30:15
格式化输出:2025年04月1009:30:15

调用静态方法

.NET 类型还有静态方法,通过 [TypeName]::MethodName() 调用。

1
2
3
4
5
6
# 常用的静态方法
Write-Host "数学运算:[Math]::Round(3.14159, 2) = $([math]::Round(3.14159, 2))"
Write-Host "最大值:[Math]::Max(10, 20) = $([math]::Max(10, 20))"
Write-Host "GUID:$([guid]::NewGuid())"
Write-Host "当前目录:$([Environment]::CurrentDirectory)"
Write-Host "机器名:$([Environment]::MachineName)"
1
2
3
4
5
数学运算:[Math]::Round(3.14159, 2) = 3.14
最大值:[Math]::Max(10, 20) = 20
GUID:a1b2c3d4-e5f6-7890-abcd-ef1234567890
当前目录:C:\Users\Admin
机器名:DESKTOP-WIN2025

自定义对象

在实际脚本开发中,经常需要创建自定义对象来组织和传递数据。PowerShell 提供了多种创建自定义对象的方式。

使用 [PSCustomObject](推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用 [PSCustomObject] 创建自定义对象
$serverInfo = [PSCustomObject]@{
ComputerName = "SRV-PROD-01"
IPAddress = "192.168.1.10"
OS = "Windows Server 2022"
CPU = 8
MemoryGB = 32
Status = "运行中"
}

# 访问属性
Write-Host "服务器名:$($serverInfo.ComputerName)"
Write-Host "内存:$($serverInfo.MemoryGB) GB"

# 动态添加属性和成员
$serverInfo | Add-Member -MemberType ScriptMethod -Name "ToString" -Force -Value {
"$($this.ComputerName) ($($this.IPAddress)) - $($this.Status)"
}

Write-Host $serverInfo.ToString()
1
2
3
服务器名:SRV-PROD-01
内存:32 GB
SRV-PROD-01 (192.168.1.10) - 运行中

批量创建对象

1
2
3
4
5
6
7
8
9
10
# 从 CSV 数据批量创建对象
$csvData = @(
"名称,部门,邮箱"
"张三,运维部,zhangsan@vichamp.com"
"李四,开发部,lisi@vichamp.com"
"王五,安全部,wangwu@vichamp.com"
)

$employees = $csvData | ConvertFrom-Csv
$employees | Format-Table -AutoSize
1
2
3
4
5
名称 部门  邮箱
---- ---- ----
张三 运维部 zhangsan@vichamp.com
李四 开发部 lisi@vichamp.com
王五 安全部 wangwu@vichamp.com

管道中的对象传递

PowerShell 管道的强大之处在于它传递的是完整对象,而非字符串。这意味着下游命令可以精确访问上游输出的属性。

1
2
3
4
5
6
7
8
# 管道传递对象示例
Get-Process |
Where-Object { $_.WorkingSet64 -gt 100MB } |
Sort-Object WorkingSet64 -Descending |
Select-Object -First 5 Name,
@{N='内存(MB)';E={[math]::Round($_.WorkingSet64 / 1MB, 1)}},
@{N='CPU(秒)';E={[math]::Round($_.TotalProcessorTime.TotalSeconds, 1)}} |
Format-Table -AutoSize
1
2
3
4
5
6
7
Name              内存(MB) CPU(秒)
---- -------- -------
chrome 245.6 127.3
devenv 189.2 85.7
powershell 95.4 12.1
ServiceHub.SettingsHost 52.3 3.8
MSBuild 48.1 15.6

管道中的每个环节接收到的都是完整的对象,Where-Object 可以精确比较数值大小,Sort-Object 可以按任何属性排序,Select-Object 可以自由选择和计算输出列。

注意事项

  • 始终用 Get-Member 来探索陌生对象的成员,这是理解 PowerShell 对象的关键
  • 注意区分属性(存储数据)和方法(执行操作),方法调用需要加括号
  • 使用 [PSCustomObject] 创建自定义对象时,哈希表中的键会变成属性名
  • 管道中传递的是对象引用,在循环中修改对象属性会影响原始数据
  • 字符串在 PowerShell 中也是对象,拥有 LengthSubstringReplace 等丰富成员
  • 当输出看似”空”时,检查对象的类型和属性名是否正确,可能只是默认显示格式问题