PowerShell 技能连载 - Azure Key Vault 密钥管理

适用于 PowerShell 7.0 及以上版本

在现代 DevOps 和云原生环境中,密钥管理是安全体系的重要基石。数据库连接字符串、API 密钥、存储账户凭据等敏感信息如果以明文形式散落在脚本、配置文件或环境变量中,一旦代码仓库泄露,攻击者就能直接获取这些凭据,进而访问生产环境的各类服务。这种安全隐患在自动化程度越高的团队中,影响面越大。

Azure Key Vault 是微软 Azure 提供的集中式密钥管理服务,支持存储机密(Secrets)、加密密钥(Keys)和证书(Certificates)。它不仅提供了硬件安全模块(HSM)保护的加密存储,还支持基于 Azure Active Directory 的细粒度访问控制、完整的访问日志审计以及自动化的密钥轮换策略。通过 PowerShell 的 Az.KeyVault 模块,我们可以将密钥的创建、读取、轮换和审计全部纳入自动化流水线。

本文将从三个实战场景出发,演示如何使用 PowerShell 完成密钥的创建与存储、在自动化脚本中安全引用密钥,以及建立密钥轮换与合规审计机制,帮助团队彻底消除脚本中的硬编码凭据。

创建 Key Vault 并存储密钥

第一步是创建 Key Vault 并将常用的敏感信息存储进去。以下脚本展示了完整的初始化流程,包括资源组创建、Key Vault 实例创建以及多种类型机密的写入。

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
# 定义变量
$ResourceGroup = 'rg-security-demo'
$Location = 'eastasia'
$VaultName = 'kv-pstip-demo-2025'

# 连接 Azure 账户(交互式登录)
Connect-AzAccount

# 创建资源组
New-AzResourceGroup -Name $ResourceGroup -Location $Location

# 创建 Key Vault(启用软删除和清除保护)
New-AzKeyVault `
-ResourceGroupName $ResourceGroup `
-VaultName $VaultName `
-Location $Location `
-EnablePurgeProtection `
-EnableSoftDelete

# 设置当前用户为机密管理员
$currentUser = (Get-AzContext).Account.Id
Set-AzKeyVaultAccessPolicy `
-VaultName $VaultName `
-UserPrincipalName $currentUser `
-PermissionsToSecrets get,set,list,delete,recover

# 存储数据库连接字符串
$dbConnectionString = 'Server=tcp:prod-sql.database.windows.net,1433;Database=AppDb;User ID=appuser;Password=P@ssw0rd!2025;'
$secretValue = ConvertTo-SecureString $dbConnectionString -AsPlainText -Force
Set-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'DatabaseConnectionString' `
-SecretValue $secretValue `
-ContentType 'text/plain'

# 存储 API 密钥(带自定义元数据和过期时间)
$apiKey = 'sk-proj-abc123def456ghi789jkl012mno345pqr678'
$apiKeySecret = ConvertTo-SecureString $apiKey -AsPlainText -Force
$expiryDate = (Get-Date).AddDays(90)
Set-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'ExternalServiceApiKey' `
-SecretValue $apiKeySecret `
-Expires $expiryDate

# 存储存储账户凭据(使用标签标记用途)
$storageKey = 'DefaultEndpointsProtocol=https;AccountName=prodstorage;AccountKey=xyz789==;EndpointSuffix=core.windows.net'
$storageSecret = ConvertTo-SecureString $storageKey -AsPlainText -Force
$tags = @{
Environment = 'Production'
Service = 'BlobStorage'
Team = 'DevOps'
RotatedDate = (Get-Date).ToString('yyyy-MM-dd')
}
Set-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'StorageAccountKey' `
-SecretValue $storageSecret `
-Tag $tags

# 列出 Vault 中所有机密
Get-AzKeyVaultSecret -VaultName $VaultName |
Select-Object Name, ContentType, Expires, Enabled |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ResourceGroupName : rg-security-demo
Location : eastasia
ProvisioningState : Succeeded

Vault Name : kv-pstip-demo-2025
Resource Group Name : rg-security-demo
Location : eastasia
Resource ID : /subscriptions/xxxx/resourceGroups/rg-security-demo/providers/Microsoft.KeyVault/vaults/kv-pstip-demo-2025
Vault URI : https://kv-pstip-demo-2025.vault.azure.net/
Tenant ID : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
SKU : Standard
Enabled For Deployment? : False
Enabled For Template Deployment? : False
Enabled For Disk Encryption? : False
Soft Delete Enabled? : True
Purge Protection Enabled? : True

Name ContentType Expires Enabled
---- ----------- ------- -------
DatabaseConnectionString text/plain True
ExternalServiceApiKey 2026-03-23 08:00:00 True
StorageAccountKey 2026-12-23 08:00:00 True

在自动化脚本中安全引用密钥

密钥存入 Key Vault 后,自动化脚本不再需要硬编码任何凭据。以下示例展示了如何使用托管标识(Managed Identity)或服务主体来读取密钥,并直接应用到数据库连接、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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 方式一:使用服务主体进行非交互式认证(适用于 CI/CD 流水线)
$tenantId = $env:AZURE_TENANT_ID
$appId = $env:AZURE_CLIENT_ID
$appSecret = ConvertTo-SecureString $env:AZURE_CLIENT_SECRET -AsPlainText -Force

$credential = New-Object System.Management.Automation.PSCredential($appId, $appSecret)
Connect-AzAccount `
-ServicePrincipal `
-TenantId $tenantId `
-Credential $credential

# 方式二:使用托管标识(适用于 Azure VM / App Service / Functions)
# Connect-AzAccount -Identity

$VaultName = 'kv-pstip-demo-2025'

# 读取数据库连接字符串
$dbSecret = Get-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'DatabaseConnectionString'
$dbConnectionString = $dbSecret.SecretValue | ConvertFrom-SecureString -AsPlainText

# 使用密钥连接数据库(以 SqlConnection 为例)
$connection = New-Object System.Data.SqlClient.SqlConnection($dbConnectionString)
$connection.Open()
Write-Host "数据库连接成功,服务器版本:$($connection.ServerVersion)"
$connection.Close()

# 读取 API 密钥并调用外部服务
$apiSecret = Get-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'ExternalServiceApiKey'
$apiKey = $apiSecret.SecretValue | ConvertFrom-SecureString -AsPlainText

$headers = @{
'Authorization' = "Bearer $apiKey"
'Content-Type' = 'application/json'
}
$response = Invoke-RestMethod `
-Uri 'https://api.example.com/v1/status' `
-Headers $headers `
-Method Get

Write-Host "API 调用成功,状态:$($response.status)"

# 封装为可复用的辅助函数
function Get-KeyVaultSecretText {
<#
.SYNOPSIS
从 Key Vault 读取机密并返回明文字符串
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$VaultName,

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

[string]$Version
)

$params = @{
VaultName = $VaultName
Name = $SecretName
}
if ($Version) {
$params['Version'] = $Version
}

$secret = Get-AzKeyVaultSecret @params
if (-not $secret) {
throw "机密 '$SecretName' 在 Vault '$VaultName' 中不存在"
}

return $secret.SecretValue | ConvertFrom-SecureString -AsPlainText
}

# 使用辅助函数
$storageKey = Get-KeyVaultSecretText -VaultName $VaultName -SecretName 'StorageAccountKey'
Write-Host "成功读取存储账户密钥,长度:$($storageKey.Length) 字符"

执行结果示例:

1
2
3
数据库连接成功,服务器版本:Microsoft SQL Server 2019 (RTM)
API 调用成功,状态:healthy
成功读取存储账户密钥,长度:142 字符

密钥轮换策略与合规审计

密钥的定期轮换是安全合规的基本要求。以下脚本演示了如何实现自动化的密钥轮换流程,并通过 Key Vault 的诊断日志功能进行访问审计,确保所有密钥操作可追溯。

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
$VaultName = 'kv-pstip-demo-2025'

# --- 密钥轮换 ---

# 查找即将过期(30 天内)的机密
$warningDate = (Get-Date).AddDays(30)
$expiringSecrets = Get-AzKeyVaultSecret -VaultName $VaultName |
Where-Object { $_.Expires -and $_.Expires -le $warningDate -and $_.Enabled }

Write-Host "发现 $($expiringSecrets.Count) 个即将过期的机密:"
$expiringSecrets | Select-Object Name, Expires | Format-Table

# 轮换函数:生成新密钥值并更新到 Key Vault
function Invoke-SecretRotation {
[CmdletBinding()]
param(
[string]$VaultName,
[string]$SecretName,
[int]$ValidityDays = 90
)

# 获取当前机密信息
$current = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName
if (-not $current) {
Write-Warning "机密 $SecretName 不存在,跳过"
return
}

# 根据机密类型生成新值(示例使用随机字符串)
$newSecretValue = -join ((48..57) + (65..90) + (97..122) |
Get-Random -Count 48 |
ForEach-Object { [char]$_ })

$newSecret = ConvertTo-SecureString $newSecretValue -AsPlainText -Force

# 更新机密(Key Vault 会自动创建新版本)
$newExpiry = (Get-Date).AddDays($ValidityDays)
Set-AzKeyVaultSecret `
-VaultName $VaultName `
-Name $SecretName `
-SecretValue $newSecret `
-Expires $newExpiry `
-Tag @{
RotatedDate = (Get-Date).ToString('yyyy-MM-dd')
RotatedBy = $env:USERNAME
PreviousVersion = $current.Version
}

Write-Host "已轮换机密 '$SecretName',新过期时间:$newExpiry"
}

# 对即将过期的机密执行轮换
foreach ($secret in $expiringSecrets) {
Invoke-SecretRotation `
-VaultName $VaultName `
-SecretName $secret.Name `
-ValidityDays 90
}

# --- 合规审计 ---

# 查看 Key Vault 的访问策略
$accessPolicies = (Get-AzKeyVault -VaultName $VaultName).AccessPolicies
Write-Host "`n当前访问策略(共 $($accessPolicies.Count) 条):"
$accessPolicies | ForEach-Object {
[PSCustomObject]@{
PrincipalId = $_.ObjectId
Permissions = ($_.PermissionsToSecrets -join ', ')
ApplicationId = $_.ApplicationId
}
} | Format-Table -AutoSize

# 获取机密版本历史(确认轮换记录)
$secretVersions = Get-AzKeyVaultSecret `
-VaultName $VaultName `
-Name 'ExternalServiceApiKey' `
-IncludeVersions |
Select-Object Version, Created, Expires, Enabled |
Sort-Object Created -Descending |
Select-Object -First 5

Write-Host "`n机密 'ExternalServiceApiKey' 最近 5 个版本:"
$secretVersions | Format-Table -AutoSize

# 生成合规报告
$allSecrets = Get-AzKeyVaultSecret -VaultName $VaultName
$report = $allSecrets | ForEach-Object {
$detail = Get-AzKeyVaultSecret -VaultName $VaultName -Name $_.Name
[PSCustomObject]@{
Name = $_.Name
Enabled = $_.Enabled
Created = $_.Created.ToString('yyyy-MM-dd')
Updated = $_.Updated.ToString('yyyy-MM-dd')
Expires = if ($_.Expires) { $_.Expires.ToString('yyyy-MM-dd') } else { '永不过期' }
DaysUntilExpiry = if ($_.Expires) { ($_.Expires - (Get-Date)).Days } else { 'N/A' }
Status = if (-not $_.Enabled) { '已禁用' }
elseif ($_.Expires -and $_.Expires -le (Get-Date)) { '已过期' }
elseif ($_.Expires -and ($_.Expires - (Get-Date)).Days -le 30) { '即将过期' }
else { '正常' }
}
}

$reportPath = "./keyvault-compliance-report-$(Get-Date -Format 'yyyyMMdd').csv"
$report | Export-Csv -Path $reportPath -NoTypeInformation -Encoding Utf8
Write-Host "`n合规报告已导出:$reportPath"
$report | Format-Table -AutoSize

执行结果示例:

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
发现 2 个即将过期的机密:

Name Expires
---- -------
ExternalServiceApiKey 2026-03-23 08:00:00
StorageAccountKey 2025-12-30 08:00:00

已轮换机密 'ExternalServiceApiKey',新过期时间:2026-03-23 08:00:00
已轮换机密 'StorageAccountKey',新过期时间:2026-03-22 08:00:00

当前访问策略(共 1 条):

PrincipalId Permissions ApplicationId
----------- ------------ -------------
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx get,set,list,delete,recover,purge,backup,restore

机密 'ExternalServiceApiKey' 最近 5 个版本:

Version Created Expires Enabled
------- ------- ------- -------
abc12345def67890 2025-12-23 08:00:00 2026-03-23 08:00:00 True
fed09876cba54321 2025-12-23 07:55:00 2026-03-23 08:00:00 True

合规报告已导出:./keyvault-compliance-report-20251223.csv

Name Enabled Created Updated Expires DaysUntilExpiry Status
---- ------- ------- ------- ------- --------------- ------
DatabaseConnectionString True 2025-12-23 2025-12-23 永不过期 N/A 正常
ExternalServiceApiKey True 2025-12-23 2025-12-23 2026-03-23 90 正常
StorageAccountKey True 2025-12-23 2025-12-23 2026-03-22 89 正常

注意事项

  1. 使用托管标识代替服务主体:在 Azure VM、App Service 或 Azure Functions 中运行脚本时,优先使用系统分配的托管标识(Connect-AzAccount -Identity),避免在环境中再存储服务主体的凭据,从根本上消除凭据泄露风险。

  2. 启用软删除和清除保护:创建 Key Vault 时务必启用 EnableSoftDeleteEnablePurgeProtection。软删除保留了 90 天的恢复窗口,清除保护则防止恶意删除后立即清空,两者配合可以有效防御勒索类攻击。

  3. 遵循最小权限原则:为不同的应用和服务主体分配最小必要的权限。只读工作负载只需 GetList 权限,轮换脚本需要 SetDelete 权限。避免为所有主体授予完全控制权限。

  4. 密钥轮换后同步下游服务:轮换密钥后,所有使用该密钥的下游应用需要及时获取新版本。建议在应用层实现自动重试逻辑,或者在轮换后触发一次服务重启或配置热加载,确保不会出现认证失败。

  5. 定期审计访问日志:通过 Key Vault 的诊断设置,将访问日志导出到 Log Analytics 工作区或存储账户。定期检查异常访问模式(如非工作时间的频繁读取、未知主体的访问尝试),及时发现潜在的安全威胁。

  6. 不要将密钥输出到日志或控制台:在调试时避免使用 Write-Host 输出机密明文。如果必须记录操作,只记录机密名称和操作结果,永远不要记录 SecretValue 的内容。CI/CD 流水线中也应屏蔽包含密钥值的变量输出。

PowerShell 技能连载 - Windows Terminal 自动化

适用于 PowerShell 7.0 及以上版本

Windows Terminal 自 2019 年发布以来,已经迅速成为 Windows 平台上最受欢迎的终端应用。它不仅支持多标签页、分屏布局、GPU 加速文本渲染,还提供了完善的 JSON 配置体系。对于系统运维工程师来说,每天启动工作环境时手动打开多个标签、连接不同服务器、调整窗口布局是一项重复且耗时的工作。

借助 PowerShell 对 JSON 配置文件的读写能力,以及 wt.exe 命令行工具的强大参数支持,我们可以将这套流程完全自动化——一条命令就能启动包含多个标签和分屏的完整工作环境。更进一步,还能批量管理配色方案、快捷键绑定等个性化配置,在团队内实现统一的工作环境标准化。

本文将围绕 Windows Terminal 的配置读取与修改、多标签分屏自动化启动、以及主题配色自定义三个方面,展示如何用 PowerShell 打造一套”开箱即用”的终端工作流。

读取和修改 Windows Terminal 配置

Windows Terminal 的所有配置存储在 settings.json 文件中。通过 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
72
73
74
75
function Get-WTSettings {
<#
.SYNOPSIS
获取 Windows Terminal 的 settings.json 配置对象
#>
$settingsPath = Join-Path $env:LOCALAPPDATA `
"Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"

if (-not (Test-Path $settingsPath)) {
# 兼容预览版路径
$settingsPath = Join-Path $env:LOCALAPPDATA `
"Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json"
}

if (-not (Test-Path $settingsPath)) {
throw "未找到 Windows Terminal 配置文件"
}

$content = Get-Content -Path $settingsPath -Raw -Encoding UTF8
# 去除 JSON 注释(简易处理)
$cleanJson = $content -replace '(?m)//.*?$', ''
$config = $cleanJson | ConvertFrom-Json
return @{
Config = $config
Path = $settingsPath
Raw = $content
}
}

function Set-WTDefaultProfile {
<#
.SYNOPSIS
设置 Windows Terminal 的默认配置文件(Profile)
.PARAMETER ProfileName
要设为默认的配置文件名称,例如 "PowerShell 7"
#>
param(
[Parameter(Mandatory)]
[string]$ProfileName
)

$settings = Get-WTSettings
$config = $settings.Config

# 在 profiles.list 中查找目标配置
$target = $config.profiles.list | Where-Object {
$_.name -eq $ProfileName
} | Select-Object -First 1

if (-not $target) {
throw "未找到名为 '$ProfileName' 的配置文件"
}

# 更新默认 Profile GUID
$config.defaultProfile = $target.guid

# 写回文件,保留可读格式
$config | ConvertTo-Json -Depth 20 |
Set-Content -Path $settings.Path -Encoding UTF8

Write-Host "已将默认 Profile 设为: $ProfileName ($($target.guid))"
}

# 示例:列出所有可用的 Profile
$settings = Get-WTSettings
$settings.Config.profiles.list | ForEach-Object {
[PSCustomObject]@{
Name = $_.name
GUID = $_.guid
Hidden = $_.hidden
}
} | Format-Table -AutoSize

# 示例:将 PowerShell 7 设为默认终端
Set-WTDefaultProfile -ProfileName "PowerShell 7"

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                       GUID                                 Hidden
---- ---- ------
PowerShell 7 {574e775e-4f2a-5b96-ac1e-a2962a402336} False
Command Prompt {0caa0dad-35be-5f56-a8ff-afceeeaa6101} False
Windows PowerShell {61c54bbd-c2c6-5271-96e7-009a87ff44bf} False
Azure Cloud Shell {b453cfc9-5a51-5a7e-889c-f8e96be50e27} True
Ubuntu (WSL) {2c4de342-38b7-51cf-b940-2309a097f518} False

已将默认 Profile 设为: PowerShell 7 ({574e775e-4f2a-5b96-ac1e-a2962a402336})

自动化启动多标签分屏布局

Windows Terminal 提供了 wt.exe 命令行工具,支持通过参数指定标签页、分屏布局和启动命令。我们可以封装一个 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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
function Start-WTOpsWorkbench {
<#
.SYNOPSIS
一键启动运维工作台:自动打开多个标签页和分屏窗格
.PARAMETER Environment
目标环境名称:dev / staging / production
#>
param(
[ValidateSet("dev", "staging", "production")]
[string]$Environment = "dev"
)

# 定义各环境的服务器列表
$envConfig = @{
dev = @{
servers = @("dev-web-01", "dev-db-01")
profile = "PowerShell 7"
}
staging = @{
servers = @("stg-web-01", "stg-web-02", "stg-db-01")
profile = "PowerShell 7"
}
production = @{
servers = @("prod-web-01", "prod-web-02", "prod-db-01", "prod-monitor")
profile = "PowerShell 7"
}
}

$config = $envConfig[$Environment]
$servers = $config.servers
$profile = $config.profile

# 构建 wt.exe 参数
$wtArgs = @()

# 第一个标签:本地 PowerShell + 系统监控
$wtArgs += "-p"
$wtArgs += "`"$profile`""
$wtArgs += ";"
$wtArgs += "split-pane"
$wtArgs += "-V"
$wtArgs += "-p"
$wtArgs += "`"$profile`""
$wtArgs += "--"
$wtArgs += "pwsh"
$wtArgs += "-NoExit"
$wtArgs += "-Command"
$wtArgs += '"Write-Host \"[系统监控] 本地环境\" -ForegroundColor Cyan; Get-Process | Sort-Object CPU -Descending | Select-Object -First 10"'

# 后续标签:每台服务器一个标签
foreach ($server in $servers) {
$wtArgs += ";"
$wtArgs += "new-tab"
$wtArgs += "-p"
$wtArgs += "`"$profile`""
$wtArgs += "--"
$wtArgs += "pwsh"
$wtArgs += "-NoExit"
$wtArgs += "-Command"
$wtArgs += "\"Enter-PSSession -ComputerName $server -ConfigurationName PowerShell.7\""
}

# 最后一个标签:日志查看
$wtArgs += ";"
$wtArgs += "new-tab"
$wtArgs += "-p"
$wtArgs += "`"$profile`""
$wtArgs += "--"
$wtArgs += "pwsh"
$wtArgs += "-NoExit"
$wtArgs += "-Command"
$wtArgs += '"Write-Host \"[日志中心] $Environment 环境\" -ForegroundColor Yellow; Get-Content \\logserver\logs\app.log -Tail 50 -Wait"'

Write-Host "正在启动 $Environment 环境运维工作台..." -ForegroundColor Green
Write-Host " 服务器: $($servers -join ', ')" -ForegroundColor Gray
Write-Host " 标签数: $($servers.Count + 2)" -ForegroundColor Gray

Start-Process -FilePath "wt.exe" -ArgumentList $wtArgs
}

# 一键启动开发环境工作台
Start-WTOpsWorkbench -Environment dev

# 启动生产环境工作台
# Start-WTOpsWorkbench -Environment production

执行结果示例:

1
2
3
正在启动 dev 环境运维工作台...
服务器: dev-web-01, dev-db-01
标签数: 4

此时 Windows Terminal 会自动打开,包含 4 个标签页:第一个标签上下分屏显示本地 PowerShell 和系统监控,第二个标签通过 Enter-PSSession 连接 dev-web-01,第三个标签连接 dev-db-01,第四个标签实时跟踪日志文件。

自定义主题配色与快捷操作

Windows Terminal 的配色方案(Color Scheme)和快捷键绑定(Keybindings / Actions)同样存储在 settings.json 中。我们可以用 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
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
function Add-WTColorScheme {
<#
.SYNOPSIS
向 Windows Terminal 添加自定义配色方案
.PARAMETER Name
配色方案名称
.PARAMETER Style
预设风格:Dracula / Solarized / Nord / TokyoNight
#>
param(
[Parameter(Mandatory)]
[string]$Name,
[ValidateSet("Dracula", "Solarized", "Nord", "TokyoNight")]
[string]$Style = "Dracula"
)

$presets = @{
Dracula = @{
"background" = "#282a36"
"foreground" = "#f8f8f2"
"cursorColor" = "#f8f8f2"
"black" = "#21222c"
"red" = "#ff5555"
"green" = "#50fa7b"
"yellow" = "#f1fa8c"
"blue" = "#bd93f9"
"purple" = "#ff79c6"
"cyan" = "#8be9fd"
"white" = "#f8f8f2"
"brightBlack" = "#6272a4"
"brightRed" = "#ff6e6e"
"brightGreen" = "#69ff94"
"brightYellow"= "#ffffa5"
"brightBlue" = "#d6acff"
"brightPurple"= "#ff92df"
"brightCyan" = "#a4ffff"
"brightWhite" = "#ffffff"
}
Nord = @{
"background" = "#2e3440"
"foreground" = "#d8dee9"
"cursorColor" = "#d8dee9"
"black" = "#3b4252"
"red" = "#bf616a"
"green" = "#a3be8c"
"yellow" = "#ebcb8b"
"blue" = "#81a1c1"
"purple" = "#b48ead"
"cyan" = "#88c0d0"
"white" = "#e5e9f0"
"brightBlack" = "#4c566a"
"brightRed" = "#bf616a"
"brightGreen" = "#a3be8c"
"brightYellow"= "#ebcb8b"
"brightBlue" = "#81a1c1"
"brightPurple"= "#b48ead"
"brightCyan" = "#8fbcbb"
"brightWhite" = "#eceff4"
}
}

$settings = Get-WTSettings
$config = $settings.Config
$rawJson = $settings.Raw

$scheme = $presets[$Style]
$scheme["name"] = $Name

# 将新配色方案追加到 schemes 数组
$schemeJson = $scheme | ConvertTo-Json -Depth 5
$newSchemesJson = $rawJson -replace \
'"schemes"\s*:\s*\[', `
("`"schemes`": [$schemeJson,")

Set-Content -Path $settings.Path -Value $newSchemesJson -Encoding UTF8
Write-Host "已添加配色方案: $Name ($Style 风格)" -ForegroundColor Green
}

function Export-WTThemeConfig {
<#
.SYNOPSIS
导出当前 Windows Terminal 的配色方案为独立 JSON 文件,方便团队共享
.PARAMETER OutputPath
导出文件路径
#>
param(
[string]$OutputPath = ".\wt-colorschemes.json"
)

$settings = Get-WTSettings
$schemes = $settings.Config.schemes

$schemes | ConvertTo-Json -Depth 10 |
Set-Content -Path $OutputPath -Encoding UTF8

Write-Host "已导出 $($schemes.Count) 个配色方案到: $OutputPath" -ForegroundColor Green

# 列出所有配色方案名称
$schemes | ForEach-Object {
" - $($_.name)"
} | Write-Host
}

function Set-WTQuickActions {
<#
.SYNOPSIS
向 Windows Terminal 添加实用快捷操作
#>

$quickActions = @(
@{
command = "splitPane"
name = "水平分屏"
keys = "ctrl+shift+bar"
},
@{
command = @{
action = "splitPane"
split = "vertical"
}
name = "垂直分屏"
keys = "ctrl+shift+plus"
},
@{
command = @{
action = "sendInput"
input = "cls`r"
}
name = "快速清屏"
keys = "ctrl+shift+k"
}
)

$settings = Get-WTSettings
$config = $settings.Config

# 追加快捷操作到 actions 列表
foreach ($action in $quickActions) {
# 检查是否已存在同名操作
$existing = $config.actions | Where-Object {
$_.name -eq $action.name
}
if (-not $existing) {
$config.actions += $action
Write-Host " 已添加快捷操作: $($action.name) [$($action.keys)]" -ForegroundColor Cyan
} else {
Write-Host " 已存在,跳过: $($action.name)" -ForegroundColor DarkGray
}
}

$config | ConvertTo-Json -Depth 20 |
Set-Content -Path $settings.Path -Encoding UTF8

Write-Host "`n快捷操作配置完成" -ForegroundColor Green
}

# 添加 Dracula 风格配色方案
Add-WTColorScheme -Name "MyDracula" -Style Dracula

# 导出配色方案供团队使用
Export-WTThemeConfig -OutputPath ".\team-wt-colors.json"

# 添加快捷操作
Set-WTQuickActions

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
已添加配色方案: MyDracula (Dracula 风格)
已导出 6 个配色方案到: .\team-wt-colors.json
- Campbell
- Campbell Powershell
- One Half Dark
- One Half Light
- Solarized Dark
- MyDracula
已添加快捷操作: 水平分屏 [ctrl+shift+bar]
已添加快捷操作: 垂直分屏 [ctrl+shift+plus]
已添加快捷操作: 快速清屏 [ctrl+shift+k]

快捷操作配置完成

注意事项

  1. 配置文件备份:修改 settings.json 前务必先备份原文件(Copy-Item 即可)。Windows Terminal 在运行时可能随时写入配置,建议在 Terminal 关闭状态下执行修改操作,避免文件被覆盖导致配置丢失。

  2. JSON 注释兼容性settings.json 中可能包含 // 注释,标准的 ConvertFrom-Json 不支持注释。示例中使用了简易的正则去除注释,对于复杂场景建议使用 System.Text.JsonJsonNode API(PowerShell 7.4+)或第三方模块处理。

  3. wt.exe 参数转义wt.exe 的命令行参数中,分号 ; 是命令分隔符,双引号需要正确转义。在 PowerShell 中拼接参数时,注意内层引号与外层引号的嵌套关系,建议先用 $wtArgs -join ' ' 输出完整命令行检查一遍再执行。

  4. Profile GUID 稳定性:Windows Terminal 使用 GUID 标识每个 Profile,不同机器上的 GUID 可能不同。在脚本中应优先通过 Profile 名称(name 字段)查找,而非硬编码 GUID,确保脚本的可移植性。

  5. 路径兼容性:Windows Terminal 正式版和预览版的 settings.json 路径不同(包名包含 WindowsTerminalWindowsTerminalPreview)。脚本中应同时检测两个路径,也可以通过 Get-ChildItem$env:LOCALAPPDATA\Packages 下动态搜索。

  6. 远程会话依赖:使用 Enter-PSSession 连接远程服务器时,目标机器需要启用 WinRM 服务并配置好 PowerShell Remoting。生产环境中建议使用 JEA(Just Enough Administration)端点限制权限,并通过 -ConfigurationName 参数指定受限的会话配置。

PowerShell 技能连载 - SSH 远程管理

适用于 PowerShell 7.0 及以上版本

传统的 PowerShell Remoting 基于 WinRM(Windows Remote Management)协议,虽然功能强大,但存在明显的平台限制——它只能在 Windows 环境中工作。在企业混合环境中,运维人员往往需要同时管理 Windows 和 Linux 服务器,WinRM 的局限性就成了一个棘手的问题。此外,WinRM 在防火墙策略严格的网络中配置繁琐,端口和认证方式也不够灵活。

PowerShell 7 引入了基于 SSH 的远程会话支持,彻底改变了这一局面。通过 Enter-PSSessionInvoke-Command-HostName 参数,PowerShell 可以通过 SSH 协议建立远程连接,实现真正的跨平台远程管理。这意味着你可以从 Windows 管理 Linux 服务器、从 Linux 管理 Windows 服务器,甚至可以在云环境和容器中使用统一的远程管理体验。

SSH Remoting 不仅能与现有的 SSH 基础设施无缝集成,还支持密钥认证、跳板机(ProxyJump)、多主机并行执行等高级场景。本文将介绍如何配置和使用 PowerShell SSH Remoting,以及在混合环境中的实战技巧。

SSH 远程会话配置与基础操作

在使用 SSH Remoting 之前,需要确保目标主机已安装并运行 SSH 服务。PowerShell 7 在连接时会使用系统自带的 SSH 客户端,因此无需额外安装 WinRM。

以下示例展示如何通过 SSH 建立交互式远程会话和执行远程命令:

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
# 查看当前 PowerShell 版本,确认是否支持 SSH Remoting
$PSVersionTable.PSVersion

# 使用 Enter-PSSession 通过 SSH 连接到远程 Linux 主机
Enter-PSSession -HostName admin@192.168.1.100
# 在远程会话中执行命令
hostname
uname -a
whoami
# 退出远程会话
Exit-PSSession

# 使用 Invoke-Command 通过 SSH 在远程主机上执行脚本块
Invoke-Command -HostName admin@192.168.1.100 -ScriptBlock {
# 获取系统信息
$osInfo = Get-Content /etc/os-release | ConvertFrom-StringData
[PSCustomObject]@{
Distribution = $osInfo.NAME
Version = $osInfo.VERSION
Kernel = uname -r
Uptime = (uptime -p)
MemoryTotal = "#{math.Round((Get-Content /proc/meminfo |
Select-String 'MemTotal').ToString().Split()[1] / 1MB, 2)} GB"
}
}

# 指定 SSH 端口(非默认 22 端口的情况)
$session = New-PSSession -HostName admin@192.168.1.100 -Port 2222
Invoke-Command -Session $session -ScriptBlock {
systemctl status nginx --no-pager
}
Remove-PSSession $session

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Major  Minor  Patch  PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7 4 0

[admin@192.168.1.100]: PS /home/admin> hostname
web-server-01
[admin@192.168.1.100]: PS /home/admin> uname -a
Linux web-server-01 5.15.0-91-generic #101-Ubuntu SMP x86_64 GNU/Linux
[admin@192.168.1.100]: PS /home/admin> whoami
admin

Distribution : Ubuntu 22.04.3 LTS
Version : 22.04.3 LTS (Jammy Jellyfish)
Kernel : 5.15.0-91-generic
Uptime : up 42 days, 3 hours
MemoryTotal : 15.62 GB

nginx.service - A high performance web server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
Active: active (running) since Mon 2025-12-01 08:00:00 UTC

SSH 密钥认证与多主机管理

密码认证虽然简单,但在自动化场景中存在安全和管理上的问题。SSH 密钥认证不仅更安全,还能让脚本在无人值守的情况下自动运行。PowerShell 7 的 SSH Remoting 原生支持密钥认证,同时可以通过 SSH 配置文件管理多个目标主机。

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
# 生成 SSH 密钥对(如果尚未生成)
ssh-keygen -t ed25519 -C "powershell-remoting" -f ~/.ssh/id_ed25519

# 将公钥复制到远程主机(使用 ssh-copy-id)
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.100
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.101
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.102

# 配置 SSH config 文件,简化连接管理
$sshConfig = @"
Host web-server
HostName 192.168.1.100
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519

Host db-server
HostName 192.168.1.101
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519

Host app-server
HostName 192.168.1.102
User admin
Port 2222
IdentityFile ~/.ssh/id_ed25519
"@

# 将配置写入 SSH config 文件
$sshConfigPath = "$HOME/.ssh/config"
$sshConfig | Set-Content -Path $sshConfigPath -Force
(Get-Item $sshConfigPath).Attributes = 'ReadOnly'

# 使用 SSH 别名直接连接(无需输入密码)
Enter-PSSession -HostName web-server

# 通过密钥认证在多台主机上并行执行命令
$servers = @('web-server', 'db-server', 'app-server')
$results = Invoke-Command -HostName $servers -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME ?? hostname
OS = try { (Get-Content /etc/os_release -ErrorAction Stop |
Select-String 'PRETTY_NAME').ToString().Split('"')[1] }
catch { $PSVersionTable.OS }
PSVersion = $PSVersionTable.PSVersion.ToString()
CPU_Usage = (Get-Process | Measure-Object CPU -Maximum).Maximum
Disk_Free = (Get-PSResourceInfo -Available -ErrorAction SilentlyContinue |
Measure-Object).Count
}
} | Select-Object Host, OS, PSVersion

$results | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
Generating public/private ed25519 key pair.
Your identification has been saved in /home/user/.ssh/id_ed25519
Your public key has been saved in /home/user/.ssh/id_ed25519.pub

Host OS PSVersion
---- -- ---------
web-server Ubuntu 22.04.3 LTS 7.4.0
db-server Ubuntu 22.04.3 LTS 7.4.0
app-server Debian 12.4 7.3.2

混合环境自动化:Windows + Linux 批量操作

在企业环境中,Windows 和 Linux 服务器通常共存。SSH Remoting 让我们可以用统一的 PowerShell 脚本同时管理两种平台,而不需要分别为 WinRM 和 SSH 编写不同的管理逻辑。下面的示例展示了如何在混合环境中进行批量巡检和配置管理。

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
# 定义混合主机列表(Windows 用 -ComputerName,Linux 用 -HostName)
$windowsServers = @('WIN-SVR01', 'WIN-SVR02')
$linuxServers = @('web-server', 'db-server', 'app-server')

# Windows 主机通过 WinRM 获取信息
$winResults = Invoke-Command -ComputerName $windowsServers -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME
Platform = 'Windows'
OS_Version = [System.Environment]::OSVersion.VersionString
CPU_Count = $env:NUMBER_OF_PROCESSORS
Memory_GB = [math]::Round(
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2
)
Disk_Free_GB = [math]::Round(
(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
Measure-Object FreeSpace -Sum).Sum / 1GB, 2
)
Uptime_Days = ((Get-Date) - (Get-CimInstance Win32_OperatingSystem).
LastBootUpTime).Days
}
}

# Linux 主机通过 SSH 获取信息
$linuxResults = Invoke-Command -HostName $linuxServers -ScriptBlock {
$memInfo = Get-Content /proc/meminfo
$memTotal = [math]::Round(
($memInfo | Select-String 'MemTotal').ToString().Split()[1] / 1MB, 2
)
$diskFree = [math]::Round(
(df -BG / | Select-Object -Last 1).ToString().Split()[3] -replace 'G',''
)
$uptimeDays = [math]::Floor(
(Get-Content /proc/uptime).ToString().Split('.')[0] / 86400
)

[PSCustomObject]@{
Host = hostname
Platform = 'Linux'
OS_Version = (Get-Content /etc/os-release |
Select-String 'PRETTY_NAME').ToString().Split('"')[1]
CPU_Count = nproc
Memory_GB = $memTotal
Disk_Free_GB = $diskFree
Uptime_Days = $uptimeDays
}
}

# 合并结果并生成巡检报告
$report = $winResults + $linuxResults |
Sort-Object Platform, Host |
Format-Table Host, Platform, OS_Version, CPU_Count,
Memory_GB, Disk_Free_GB, Uptime_Days -AutoSize

$report | Out-String | Write-Host

# 导出为 CSV 文件
$winResults + $linuxResults |
Sort-Object Platform, Host |
Export-Csv -Path "./server-inventory-$(Get-Date -Format 'yyyyMMdd').csv" `
-NoTypeInformation -Encoding UTF8

Write-Host "巡检报告已导出到 CSV 文件" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
Host        Platform OS_Version                      CPU_Count Memory_GB Disk_Free_GB Uptime_Days
---- -------- ---------- --------- --------- ------------ -----------
WIN-SVR01 Windows Microsoft Windows Server 2022 8 32.00 156.43 45
WIN-SVR02 Windows Microsoft Windows Server 2022 4 16.00 89.21 30
app-server Linux Debian GNU/Linux 12 (bookworm) 2 7.81 34 62
db-server Linux Ubuntu 22.04.3 LTS (Jammy) 4 15.62 120 42
web-server Linux Ubuntu 22.04.3 LTS (Jammy) 2 3.91 18 42

巡检报告已导出到 CSV 文件

注意事项

  1. SSH 服务必须预装:目标 Linux 主机需要安装并启动 sshd 服务,同时确保 PowerShell 7 已安装在远端(否则 Enter-PSSession 只会进入普通的 SSH Shell,而非 PowerShell 远程会话)。

  2. 认证方式选择:生产环境应优先使用 SSH 密钥认证(Ed25519 或 RSA),避免在脚本中硬编码密码。密钥的私钥文件务必设置严格的文件权限(chmod 600)。

  3. SSH 配置文件的作用:通过 ~/.ssh/config 文件管理主机别名、端口和密钥路径,可以大幅简化 Enter-PSSession -HostName 的使用,同时还能配置跳板机(ProxyJump)和连接超时等参数。

  4. 混合环境注意区分参数:Windows 主机使用 -ComputerName(走 WinRM),Linux 主机使用 -HostName(走 SSH)。如果 Windows 主机也配置了 SSH 服务,也可以统一使用 -HostName 参数。

  5. 错误处理与超时:SSH 连接可能因网络问题超时,建议在脚本中设置 $PSSessionOption 的超时参数,并使用 try-catch 包裹远程操作,避免单台主机故障导致整个批量任务中断。

  6. 安全加固建议:建议在 sshd_config 中禁用密码登录(PasswordAuthentication no)、禁用 root 远程登录(PermitRootLogin no),并使用 AllowUsers 限制可远程连接的用户范围,以降低安全风险。

PowerShell 技能连载 - JSON Schema 验证

适用于 PowerShell 7.0 及以上版本

JSON 已经成为现代配置管理和数据交换的事实标准格式。无论是应用配置文件、CI/CD 流水线定义,还是 REST API 的请求与响应体,JSON 无处不在。然而 JSON 本身并不携带结构约束信息——一个字段是字符串还是数字、哪些字段是必填的、数值的合法范围是什么,这些都需要额外的手段来保证。

JSON Schema(RFC 8927)是一套标准化的 JSON 结构描述规范,可以用声明式的方式定义数据的形状、类型、约束和关系。相比于手写验证逻辑,使用标准的 JSON Schema 具有可复用、可共享、可版本化的优势,而且生态中有大量现成的工具和库。将 JSON Schema 验证集成到 PowerShell 脚本中,可以在自动化流水线的入口处拦截无效数据,避免后续环节出现难以排查的问题。

本文将介绍如何在 PowerShell 中利用 .NET 生态的 JsonSchema.NET 库,实现标准 JSON Schema 验证、高级条件约束以及批量配置文件的验证与报告生成。

定义 Schema 并验证配置文件

首先安装 JsonSchema.NET 库,然后用它来加载标准的 JSON Schema 定义,对配置文件进行结构验证。JsonSchema.NET 是一个纯 .NET 实现的 JSON Schema 验证器,支持 Draft 6、Draft 7 和 Draft 2020-12 版本。

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
# 安装 JsonSchema.NET 库
Install-Module -Name JsonSchema.NET -Scope CurrentUser -Force

# 或者通过 NuGet 安装(适合 CI/CD 环境)
# dotnet add package JsonSchema.NET

# 定义标准的 JSON Schema(Draft 2020-12)
$schemaJson = @"
{
"`$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"title": "应用配置 Schema",
"required": ["appName", "version", "environment", "server"],
"properties": {
"appName": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "应用名称"
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "语义化版本号"
},
"environment": {
"type": "string",
"enum": ["development", "staging", "production"],
"description": "部署环境"
},
"server": {
"type": "object",
"required": ["host", "port"],
"properties": {
"host": {
"type": "string",
"format": "hostname"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"ssl": {
"type": "boolean",
"default": false
}
},
"additionalProperties": false
},
"logging": {
"type": "object",
"properties": {
"level": {
"type": "string",
"enum": ["debug", "info", "warn", "error"],
"default": "info"
},
"path": {
"type": "string",
"format": "uri-reference"
}
}
}
},
"additionalProperties": false
}
"@

# 加载 Schema
$schema = [JsonSchema.JsonSchema]::FromText($schemaJson)

# 准备一份有效的配置
$validConfig = @"
{
"appName": "OrderService",
"version": "3.2.1",
"environment": "production",
"server": {
"host": "api.example.com",
"port": 443,
"ssl": true
},
"logging": {
"level": "warn",
"path": "/var/log/orderservice.log"
}
}
"@

# 验证并输出结果
$results = $schema.Evaluate($validConfig)
Write-Host "有效配置验证结果:$($results.IsValid)" -ForegroundColor Green

# 准备一份无效的配置——故意制造多个问题
$invalidConfig = @"
{
"appName": "",
"version": "3.2",
"environment": "testing",
"server": {
"host": "api.example.com",
"port": 99999,
"extraField": "not-allowed"
}
}
"@

$results = $schema.Evaluate($invalidConfig)
Write-Host "`n无效配置验证结果:$($results.IsValid)" -ForegroundColor Red

# 逐条输出错误详情
foreach ($detail in $results.Details) {
Write-Host (" 路径: {0,-25} 错误: {1}" -f $detail.InstanceLocation, $detail.Message) -ForegroundColor Yellow
}

执行结果示例:

1
2
3
4
5
6
7
8
有效配置验证结果:True

无效配置验证结果:False
路径: /appName 错误: 字符串长度不足,最少需要 1 个字符
路径: /version 错误: 字符串不匹配模式 ^[0-9]+\.[0-9]+\.[0-9]+$
路径: /environment 错误: 值不在枚举列表中
路径: /server/port 错误: 整数值 99999 超过最大值 65535
路径: /server 错误: 存在不允许的额外属性 extraField

Schema 高级特性——条件验证与组合模式

实际业务中,配置规则往往不是扁平的。比如生产环境必须启用 SSL、数据库连接字符串在特定环境下有不同的格式要求。JSON Schema 提供了 if/then/elseallOfoneOfanyOf 等条件组合机制,可以表达复杂的业务约束。

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
128
129
130
131
132
133
134
135
136
137
138
139
140
# 定义包含条件逻辑的复杂 Schema
$advancedSchemaJson = @"
{
"`$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"title": "部署配置 Schema(含条件规则)",
"required": ["environment", "database", "features"],
"properties": {
"environment": {
"type": "string",
"enum": ["dev", "staging", "production"]
},
"database": { "type": "object" },
"features": { "type": "object" }
},
"allOf": [
{
"if": {
"properties": {
"environment": { "const": "production" }
}
},
"then": {
"properties": {
"database": {
"required": ["connectionString", "maxPoolSize", "sslMode"],
"properties": {
"connectionString": {
"type": "string",
"minLength": 10
},
"maxPoolSize": {
"type": "integer",
"minimum": 10,
"maximum": 200
},
"sslMode": {
"type": "string",
"enum": ["require", "verify-ca", "verify-full"]
}
}
},
"features": {
"required": ["monitoring", "errorReporting"],
"properties": {
"monitoring": { "type": "boolean", "const": true },
"errorReporting": { "type": "boolean", "const": true }
}
}
}
}
},
{
"if": {
"properties": {
"environment": { "const": "dev" }
}
},
"then": {
"properties": {
"database": {
"required": ["connectionString"],
"properties": {
"connectionString": { "type": "string" },
"maxPoolSize": { "type": "integer", "default": 5 }
}
}
}
}
}
],
"oneOf": [
{
"properties": {
"features": {
"properties": {
"cacheProvider": { "const": "redis" }
},
"required": ["redisHost"]
}
}
},
{
"properties": {
"features": {
"properties": {
"cacheProvider": { "const": "memory" }
}
}
}
}
]
}
"@

$advSchema = [JsonSchema.JsonSchema]::FromText($advancedSchemaJson)

# 测试:生产环境缺少必填字段和监控开关
$prodConfig = @"
{
"environment": "production",
"database": {
"connectionString": "Host=localhost;Database=mydb",
"maxPoolSize": 5
},
"features": {
"monitoring": false,
"cacheProvider": "redis"
}
}
"@

$results = $advSchema.Evaluate($prodConfig)

Write-Host "=== 生产环境配置验证报告 ===" -ForegroundColor Cyan
Write-Host "是否通过:$($results.IsValid)"
Write-Host "`n验证错误明细:"
$index = 0
foreach ($detail in $results.Details) {
$index++
Write-Host (" [{0}] 路径: {1}" -f $index, $detail.InstanceLocation) -ForegroundColor Yellow
Write-Host (" 描述: {0}" -f $detail.Message) -ForegroundColor Gray
}

# 测试:开发环境的简化配置(应该通过)
$devConfig = @"
{
"environment": "dev",
"database": {
"connectionString": "Host=localhost;Database=devdb"
},
"features": {
"cacheProvider": "memory"
}
}
"@

$devResults = $advSchema.Evaluate($devConfig)
Write-Host "`n=== 开发环境配置验证报告 ===" -ForegroundColor Cyan
Write-Host "是否通过:$($devResults.IsValid)" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== 生产环境配置验证报告 ===
是否通过:False

验证错误明细:
[1] 路径: /database
描述: 缺少必填属性 sslMode
[2] 路径: /database/maxPoolSize
描述: 整数值 5 低于最小值 10
[3] 路径: /features/monitoring
描述: 值必须为 true
[4] 路径: /features
描述: 缺少必填属性 errorReporting
[5] 路径: /features
描述: 缺少必填属性 redisHost(cacheProvider 为 redis 时必需)

=== 开发环境配置验证报告 ===
是否通过:True

批量验证与错误报告生成

在管理多个环境、多个微服务配置文件的场景下,手动逐个验证效率太低。下面的脚本实现了批量扫描指定目录下的 JSON 配置文件,使用统一的 Schema 进行验证,并生成结构化的验证报告。

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

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

[string]$ReportPath
)

# 加载 Schema
$schemaText = Get-Content $SchemaFile -Raw
$schema = [JsonSchema.JsonSchema]::FromText($schemaText)

# 收集所有 JSON 配置文件
$configFiles = Get-ChildItem -Path $ConfigDirectory -Filter "*.json" -Recurse
Write-Host ("发现 {0} 个配置文件,开始批量验证..." -f $configFiles.Count) -ForegroundColor Cyan

# 验证结果集合
$report = [System.Collections.Generic.List[PSObject]]::new()
$passCount = 0
$failCount = 0

foreach ($file in $configFiles) {
$relativePath = $file.FullName.Replace($ConfigDirectory, "").TrimStart("\", "/")
Write-Host (" 验证: {0} ... " -f $relativePath) -NoNewline

try {
$configText = Get-Content $file.FullName -Raw
$results = $schema.Evaluate($configText)

if ($results.IsValid) {
Write-Host "通过" -ForegroundColor Green
$passCount++
$report += [PSCustomObject]@{
File = $relativePath
Status = "Pass"
ErrorCount = 0
Errors = ""
ValidatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
} else {
Write-Host "失败" -ForegroundColor Red
$failCount++
$errorList = ($results.Details | ForEach-Object {
"{0} => {1}" -f $_.InstanceLocation, $_.Message
}) -join "; "

$report += [PSCustomObject]@{
File = $relativePath
Status = "Fail"
ErrorCount = $results.Details.Count
Errors = $errorList
ValidatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
} catch {
Write-Host "异常" -ForegroundColor Magenta
$failCount++
$report += [PSCustomObject]@{
File = $relativePath
Status = "Error"
ErrorCount = 1
Errors = $_.Exception.Message
ValidatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
}

# 输出汇总信息
Write-Host "`n========== 验证汇总 ==========" -ForegroundColor Cyan
Write-Host (" 总文件数:{0}" -f $configFiles.Count)
Write-Host (" 通过:{0}" -f $passCount) -ForegroundColor Green
Write-Host (" 失败:{0}" -f $failCount) -ForegroundColor Red
Write-Host (" 通过率:{0:P1}" -f ($passCount / $configFiles.Count))

# 导出报告
if ($ReportPath) {
$report | ConvertTo-Json -Depth 5 | Out-File $ReportPath -Encoding UTF8
Write-Host ("`n验证报告已保存至:{0}" -f $ReportPath) -ForegroundColor Green
}

return $report
}

# 执行批量验证
$validationResults = Invoke-BatchSchemaValidation `
-ConfigDirectory "C:\MyApp\Configs" `
-SchemaFile "C:\MyApp\Schemas\app-config.schema.json" `
-ReportPath "C:\MyApp\Reports\validation-report.json"

# 针对失败项生成修复建议
$failures = $validationResults | Where-Object { $_.Status -ne "Pass" }
if ($failures) {
Write-Host "`n========== 需要修复的文件 ==========" -ForegroundColor Yellow
foreach ($failure in $failures) {
Write-Host (" [{0}] {1}" -f $failure.Status, $failure.File) -ForegroundColor Yellow
Write-Host (" 错误数: {0}" -f $failure.ErrorCount)
foreach ($err in $failure.Errors -split "; ") {
Write-Host (" - {0}" -f $err) -ForegroundColor DarkGray
}
}
}

执行结果示例:

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
发现 6 个配置文件,开始批量验证...
验证: dev\app-settings.json ... 通过
验证: dev\cache-config.json ... 通过
验证: staging\app-settings.json ... 通过
验证: staging\cache-config.json ... 失败
验证: production\app-settings.json ... 失败
验证: production\cache-config.json ... 通过

========== 验证汇总 ==========
总文件数:6
通过:4
失败:2
通过率:66.7%

验证报告已保存至:C:\MyApp\Reports\validation-report.json

========== 需要修复的文件 ==========
[Fail] staging\cache-config.json
错误数: 1
- /server/port => 整数值 99999 超过最大值 65535
[Fail] production\app-settings.json
错误数: 3
- /database/sslMode => 缺少必填属性 sslMode
- /features/monitoring => 值必须为 true
- /features/errorReporting => 缺少必填属性 errorReporting

注意事项

  1. 库的选择:JsonSchema.NET 是纯 C# 实现,跨平台兼容性好。如果项目中已经依赖了 Newtonsoft.Json,也可以选择 Newtonsoft.Json.Schema,但它需要商业授权才能在生产环境免受限制
  2. Schema 版本兼容:不同版本的 JSON Schema(Draft-04 / Draft-07 / 2020-12)在特性支持上有差异,编写 Schema 时务必在 $schema 字段中声明正确的版本标识
  3. 大文件性能:对超过 10MB 的 JSON 文件进行完整 Schema 验证可能耗时较长,建议在 CI/CD 中仅对关键字段做校验,或采用增量验证策略
  4. 错误路径解读:验证错误中的 InstanceLocation 使用 JSON Pointer 格式(如 /server/port),可以精确定位到出错的节点,编写自动化修复脚本时应充分利用此信息
  5. Schema 即文档:维护良好的 JSON Schema 不仅是验证工具,还可以配合文档生成器(如 @adobe/jsonschema2md)自动生成配置参考手册,减少人工维护成本
  6. CI/CD 集成:建议将 Schema 验证作为部署流水线的前置步骤(gate check),任何配置文件变更必须通过验证才能合并到主分支,从源头杜绝无效配置上线

PowerShell 技能连载 - WSL 集成与互操作

适用于 PowerShell 7.0 及以上版本

Windows Subsystem for Linux (WSL) 已经从一个实验性的兼容层发展为成熟的 Linux 运行环境。WSL2 基于真正的 Linux 内核,支持 systemd、Docker 以及绝大多数原生 Linux 应用。对于日常在 Windows 上工作的运维工程师和开发者来说,WSL 提供了一条低成本的 Linux 工具链接入路径——无需双系统,无需虚拟机管理程序的开销。

PowerShell 与 WSL 的互操作不仅限于简单的命令转发。借助 wsl 命令行工具、\\wsl$ 网络路径以及双向的进程调用机制,可以在一个脚本中自由混合 Windows 和 Linux 工具。例如,用 PowerShell 采集 Windows 事件日志,再通过管道传给 WSL 中的 awk 做文本分析;或者反过来,在 WSL 中编译项目后调用 PowerShell 部署到 Windows 服务。这种混合工作流在 DevOps 和跨平台自动化场景中尤为实用。

本文将从实例管理、跨平台数据交换和自动化部署三个维度,展示 PowerShell 与 WSL 深度集成的实战技巧。

管理 WSL 实例生命周期

日常工作中,我们经常需要创建、备份、迁移 WSL 实例。PowerShell 可以把 wsl.exe 的能力封装成可复用的管理函数,实现实例的批量运维。

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
# 定义 WSL 实例管理工具集
function Get-WslInstance {
<# 获取所有 WSL 发行版的详细信息 #>
$raw = wsl --list --verbose 2>&1
$lines = $raw | Select-Object -Skip 2 | Where-Object { $_ -match '\S' }

foreach ($line in $lines) {
$parts = $line.Trim() -split '\s+'
[PSCustomObject]@{
IsDefault = $line -match '^\*'
Name = $parts[0] -replace '^\*', ''
State = $parts[1]
Version = $parts[2]
}
}
}

function Export-WslInstance {
<# 将 WSL 实例导出为 tar 文件,用于备份或迁移 #>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$OutputPath
)

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

Write-Host "正在导出 $Name ..." -ForegroundColor Cyan
wsl --export $Name $OutputPath

$size = [math]::Round((Get-Item $OutputPath).Length / 1GB, 2)
Write-Host "导出完成:$OutputPath (${size} GB)" -ForegroundColor Green
}

function Import-WslInstance {
<# 从 tar 文件导入为新的 WSL 实例 #>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$InstallPath,
[Parameter(Mandatory)][string]$SourceFile
)

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

Write-Host "正在导入 $Name ..." -ForegroundColor Cyan
wsl --import $Name $InstallPath $SourceFile
Write-Host "导入完成:$Name => $InstallPath" -ForegroundColor Green
}

function Remove-WslInstance {
<# 注销并删除 WSL 实例(谨慎使用) #>
param(
[Parameter(Mandatory)][string]$Name,
[switch]$Force
)

if (-not $Force) {
$confirm = Read-Host "确认要删除 WSL 实例 '$Name' 吗?(yes/no)"
if ($confirm -ne 'yes') {
Write-Host "已取消" -ForegroundColor Yellow
return
}
}

wsl --unregister $Name
Write-Host "已删除 WSL 实例:$Name" -ForegroundColor Red
}

# 批量备份所有运行中的实例
$instances = Get-WslInstance | Where-Object State -eq 'Running'
$backupDir = "D:\WSL-Backups\$(Get-Date -Format 'yyyyMMdd')"

foreach ($inst in $instances) {
$tar = Join-Path $backupDir "$($inst.Name).tar"
Export-WslInstance -Name $inst.Name -OutputPath $tar
}

执行结果示例:

1
2
3
4
正在导出 Ubuntu-24.04 ...
导出完成:D:\WSL-Backups\20251217\Ubuntu-24.04.tar (1.85 GB)
正在导出 Debian ...
导出完成:D:\WSL-Backups\20251217\Debian.tar (0.92 GB)

跨平台命令调用与数据交换

PowerShell 与 WSL 之间的数据交换是互操作的核心。下面展示几种常见模式:结构化数据传递、环境变量共享,以及双向脚本编排。

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
# 封装一个通用的 WSL 命令执行器
function Invoke-WslCommand {
param(
[Parameter(Mandatory)][string]$Command,
[string]$Distribution,
[switch]$AsJson
)

$args = @()
if ($Distribution) { $args += '-d', $Distribution }
$args += '--', 'bash', '-c', $Command

$result = & wsl @args 2>&1
$result = $result | Out-String

if ($AsJson -and $result) {
try {
return $result | ConvertFrom-Json
} catch {
Write-Warning "JSON 解析失败,返回原始文本"
return $result
}
}
return $result.Trim()
}

# 场景 1:用 Linux 工具分析 Windows 日志
# 先用 PowerShell 导出事件日志为 CSV,再用 WSL 的 awk 统计
$csvPath = "$env:TEMP\security_events.csv"
Get-WinEvent -LogName Security -MaxEvents 500 |
Select-Object TimeCreated, Id, LevelDisplayName, Message |
Export-Csv $csvPath -NoTypeInformation -Encoding UTF8

# 将 Windows 路径转为 WSL 可访问的路径
$wslCsv = ($csvPath -replace '^([A-Z]):', { '/mnt/' + $_.Groups[1].Value.ToLower() } `
-replace '\\', '/')

$stats = Invoke-WslCommand "awk -F',' '{print `$3}' $wslCsv | sort | uniq -c | sort -rn"
Write-Host "安全事件级别分布:" -ForegroundColor Cyan
Write-Host $stats

# 场景 2:从 WSL 获取系统信息,返回结构化对象
$sysInfo = Invoke-WslCommand -AsJson -Command @"
cat /etc/os-release | grep -E '^(NAME|VERSION)=' | while IFS='=' read -r k v; do echo "\"`$k\": \"`$v\","; done | sed '1s/^/{/;$s/,$/}/'
"@

if ($sysInfo -is [string]) {
# 备用方案:手动收集
$osName = (Invoke-WslCommand "cat /etc/os-release | grep '^NAME=' | cut -d'=' -f2").Trim('"')
$osVer = (Invoke-WslCommand "cat /etc/os-release | grep '^VERSION=' | cut -d'=' -f2").Trim('"')
$sysInfo = [PSCustomObject]@{ NAME = $osName; VERSION = $osVer }
}

Write-Host "`nWSL 系统信息:" -ForegroundColor Cyan
Write-Host " 发行版:$($sysInfo.NAME)"
Write-Host " 版本:$($sysInfo.VERSION)"

# 场景 3:利用 WSL 中独有的工具处理数据
# 使用 xq(jq 的 XML 版本)解析 Windows 无法原生处理的 XML
$xmlReport = Invoke-WslCommand @"
if command -v xq &>/dev/null; then
curl -s https://example.com/feed.xml | xq '.rss.channel.item[] | .title' 2>/dev/null
else
echo 'xq not installed, fallback to xmlstarlet'
curl -s https://example.com/feed.xml | xmlstarlet sel -t -m '//item/title' -v '.' -n 2>/dev/null
fi
"@

Write-Host "`nRSS 标题提取:" -ForegroundColor Cyan
Write-Host $xmlReport

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
安全事件级别分布:
420 信息
45 审核
28 警告
7 错误

WSL 系统信息:
发行版:Ubuntu
版本:24.04.1 LTS (Noble Numbat)

RSS 标题提取:
PowerShell 7.5 发布:新特性一览
.NET 9 正式可用
Azure 新增多云管理功能

自动化部署场景:混合 Windows/Linux 工具链

在实际的 CI/CD 和运维自动化中,经常需要将 Windows 原生工具与 Linux 工具链串联起来。下面的示例展示了一个完整的混合部署脚本:在 WSL 中编译 Node.js 项目,然后在 Windows 上完成 IIS 部署。

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
# 混合平台部署脚本
function Invoke-HybridDeploy {
param(
[string]$ProjectName = "webapp",
[string]$WslProjectPath = "/home/user/projects/$ProjectName",
[string]$WinDeployPath = "C:\inetpub\wwwroot\$ProjectName",
[string]$Distribution = "Ubuntu-24.04"
)

# 阶段 1:在 WSL 中拉取代码并构建
Write-Host "`n[1/4] 在 WSL 中拉取最新代码..." -ForegroundColor Cyan
$pullResult = Invoke-WslCommand -Distribution $Distribution -Command @"
cd $WslProjectPath && git pull origin main 2>&1 | tail -5
"@
Write-Host $pullResult

# 阶段 2:在 WSL 中执行 npm 构建
Write-Host "`n[2/4] 在 WSL 中执行构建..." -ForegroundColor Cyan
$buildResult = Invoke-WslCommand -Distribution $Distribution -Command @"
cd $WslProjectPath &&
npm ci --prefer-offline 2>&1 | tail -3 &&
npm run build 2>&1 | tail -5
"@
Write-Host $buildResult

# 阶段 3:将构建产物从 WSL 文件系统复制到 Windows
Write-Host "`n[3/4] 复制构建产物到 Windows..." -ForegroundColor Cyan
$wslDistPath = "\\wsl$\$Distribution$WslProjectPath\dist"

if (Test-Path $WinDeployPath) {
$backup = "$WinDeployPath.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Move-Item $WinDeployPath $backup -Force
Write-Host "已备份旧版本到 $backup" -ForegroundColor Yellow
}

Copy-Item $wslDistPath $WinDeployPath -Recurse -Force
$fileCount = (Get-ChildItem $WinDeployPath -Recurse -File).Count
Write-Host "已复制 $fileCount 个文件到 $WinDeployPath" -ForegroundColor Green

# 阶段 4:用 PowerShell 进行 Windows 端配置
Write-Host "`n[4/4] 更新 IIS 配置..." -ForegroundColor Cyan

# 确保 IIS 应用池存在
$poolName = "${ProjectName}Pool"
if (-not (Get-IISAppPool -Name $poolName -ErrorAction SilentlyContinue)) {
New-IISAppPool -Name $poolName -Force
Write-Host "已创建应用池:$poolName"
}

# 更新站点物理路径
$site = Get-IISSite -Name $ProjectName -ErrorAction SilentlyContinue
if ($site) {
$site.Applications["/"].VirtualDirectories["/"].PhysicalPath = $WinDeployPath
Write-Host "已更新站点路径" -ForegroundColor Green
}

# 验证部署结果
$indexPath = Join-Path $WinDeployPath "index.html"
if (Test-Path $indexPath) {
$hash = (Get-FileHash $indexPath -Algorithm SHA256).Hash.Substring(0, 16)
Write-Host "`n部署成功!index.html 校验:$hash" -ForegroundColor Green
}

# 阶段 5:健康检查(用 WSL 中的 curl)
Write-Host "`n健康检查..." -ForegroundColor Cyan
$health = Invoke-WslCommand -Distribution $Distribution -Command @"
curl -sS -o /dev/null -w '%{http_code} %{time_total}s' http://localhost:8080/ 2>/dev/null || echo 'unreachable'
"@
Write-Host "HTTP 状态:$health"
}

# 执行部署
Invoke-HybridDeploy -ProjectName "webapp"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[1/4] 在 WSL 中拉取最新代码...
Already up to date.
Updating 3f2a1b0..7c8d9e0
Fast-forward
src/App.js | 12 ++++++------
2 files changed, 6 insertions(+), 6 deletions(-)

[2/4] 在 WSL 中执行构建...
added 245 packages in 3.2s
> webapp@1.0.0 build
> vite build
✓ built in 4.21s

[3/4] 复制构建产物到 Windows...
已备份旧版本到 C:\inetpub\wwwroot\webapp.bak.20251217_083000
已复制 42 个文件到 C:\inetpub\wwwroot\webapp

[4/4] 更新 IIS 配置...
已更新站点路径

部署成功!index.html 校验:A3F8B2C1D4E5F607

健康检查...
HTTP 状态:200 0.032s

注意事项

  1. 路径风格转换:Windows 路径(C:\Users\)和 WSL 路径(/mnt/c/Users/)之间转换时,注意盘符大小写和斜杠方向,建议封装通用的转换函数避免手动拼接出错。
  2. \\wsl$ 网络路径性能:通过 \\wsl$\ 访问 WSL 文件系统比 /mnt/c 跨分区访问快得多,大文件操作优先在 WSL 原生文件系统中完成后再复制到 Windows。
  3. 环境变量隔离:Windows 和 WSL 的环境变量相互独立,$env:PATH 不会自动共享。如果需要传递变量,使用 WSLENV 配置共享映射规则。
  4. 并发安全:多个 PowerShell 脚本同时向同一个 WSL 实例发送命令可能导致输出交错,生产环境中建议对 WSL 调用加锁或使用独立实例。
  5. 退出码传递wsl -- command$LASTEXITCODE 返回的是 Linux 进程的退出码,但经过 PowerShell 管道处理后可能丢失,关键操作建议显式检查输出内容而非仅依赖退出码。
  6. 换行符陷阱:WSL 输出是 LF 换行,Windows 文件默认 CRLF。跨系统写入文本文件时注意使用统一的编码和换行符,否则可能导致配置文件解析失败。

PowerShell 技能连载 - Azure 虚拟网络管理

适用于 PowerShell 5.1 及以上版本

Azure 虚拟网络(Virtual Network,简称 VNet)是云端基础设施的基石,所有 Azure 资源之间的通信都建立在虚拟网络之上。无论是虚拟机、应用服务还是数据库,都需要通过 VNet 进行网络隔离与互联。对于运维工程师而言,掌握虚拟网络的创建、子网划分、安全组配置和对等连接等操作,是管理 Azure 环境的必备技能。

在多区域、多订阅的企业级架构中,网络配置的复杂度会迅速攀升。手动在 Azure Portal 中逐个配置子网和规则不仅耗时,还容易出现人为错误。通过 PowerShell 的 Az.Network 模块,我们可以将网络配置脚本化,实现版本控制和自动化部署,确保开发、测试、生产各环境的网络拓扑一致。

本文将从零开始演示如何使用 PowerShell 创建虚拟网络、管理子网、配置网络安全组以及建立 VNet 对等连接,帮助你构建一套可复用的网络管理脚本库。

前置准备:安装模块并连接 Azure

在操作虚拟网络之前,需要确保已安装 Az 模块并完成 Azure 认证。Az.Network 模块包含在 Az 总包中,无需单独安装。如果你在受限环境中只需要网络管理功能,也可以单独安装 Az.Network

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 安装 Az 模块(如果尚未安装)
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force

# 连接 Azure 账户(交互式登录)
Connect-AzAccount

# 查看当前订阅列表,选择目标订阅
$subscriptions = Get-AzSubscription
foreach ($sub in $subscriptions) {
Write-Host "名称: $($sub.Name) ID: $($sub.Id)"
}

# 选择目标订阅
Select-AzSubscription -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# 验证当前上下文
Get-AzContext | Select-Object Account, Subscription

执行结果示例:

1
2
3
4
5
6
名称: Visual Studio Enterprise  ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
名称: Production-Sub ID: b2c3d4e5-f6a7-8901-bcde-f12345678901

Account Subscription
------- ------------
user@example.com Production-Sub (b2c3d4e5-...)

连接成功后,我们就可以开始操作虚拟网络资源了。建议在生产操作前先在测试订阅中验证脚本。

创建虚拟网络和子网

虚拟网络的核心概念是地址空间(Address Space)和子网(Subnet)。地址空间定义了整个 VNet 的 IP 范围,子网则是从地址空间中划分出的更小网段,用于隔离不同类型的资源。

下面的脚本演示如何创建一个包含三个子网(前端、后端、数据层)的虚拟网络,这是典型的三层架构网络拓扑。

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
# 定义变量
$resourceGroup = "rg-network-demo"
$location = "EastAsia"
$vnetName = "vnet-demo"
$addressPrefix = "10.0.0.0/16"

# 创建资源组(如果不存在)
$rg = Get-AzResourceGroup -Name $resourceGroup -ErrorAction SilentlyContinue
if (-not $rg) {
$rg = New-AzResourceGroup -Name $resourceGroup -Location $location
Write-Host "已创建资源组: $resourceGroup"
}

# 定义子网配置
$subnetConfigs = @(
@{ Name = "snet-frontend"; Prefix = "10.0.1.0/24" }
@{ Name = "snet-backend"; Prefix = "10.0.2.0/24" }
@{ Name = "snet-data"; Prefix = "10.0.3.0/24" }
)

# 创建子网配置对象
$subnets = @()
foreach ($config in $subnetConfigs) {
$subnet = New-AzVirtualNetworkSubnetConfig `
-Name $config.Name `
-AddressPrefix $config.Prefix
$subnets += $subnet
Write-Host "已准备子网配置: $($config.Name) ($($config.Prefix))"
}

# 创建虚拟网络
$vnet = New-AzVirtualNetwork `
-Name $vnetName `
-ResourceGroupName $resourceGroup `
-Location $location `
-AddressPrefix $addressPrefix `
-Subnet $subnets

# 查看创建结果
$vnet | Select-Object Name, Location, AddressSpace
$vnet.Subnets | Select-Object Name, AddressPrefix

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
已创建资源组: rg-network-demo
已准备子网配置: snet-frontend (10.0.1.0/24)
已准备子网配置: snet-backend (10.0.2.0/24)
已准备子网配置: snet-data (10.0.3.0/24)

Name Location AddressSpace
---- -------- ------------
vnet-demo eastasia 10.0.0.0/16

Name AddressPrefix
---- -------------
snet-frontend 10.0.1.0/24
snet-backend 10.0.2.0/24
snet-data 10.0.3.0/24

可以看到,一个地址空间为 10.0.0.0/16 的虚拟网络已创建完成,内部包含三个独立的子网。每个子网最多可容纳 251 个可用 IP(扣除 Azure 保留的 5 个地址)。

配置网络安全组(NSG)

网络安全组(Network Security Group)是 Azure 中的软件防火墙,通过定义入站和出站规则来控制子网或网络接口级别的流量。在生产环境中,合理的 NSG 规则是保护应用安全的第一道防线。

下面演示如何创建 NSG 并配置常见的安全规则:允许 HTTP/HTTPS 入站、允许 SSH 仅来自特定 IP 段、拒绝所有其他入站流量。

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
# 创建网络安全组
$nsgName = "nsg-frontend"
$nsg = New-AzNetworkSecurityGroup `
-Name $nsgName `
-ResourceGroupName $resourceGroup `
-Location $location

# 允许 HTTP 入站(优先级 100)
$nsg | Add-AzNetworkSecurityRuleConfig `
-Name "Allow-HTTP" `
-Description "允许 HTTP 流量" `
-Access Allow `
-Protocol Tcp `
-Direction Inbound `
-Priority 100 `
-SourceAddressPrefix "Internet" `
-SourcePortRange "*" `
-DestinationAddressPrefix "10.0.1.0/24" `
-DestinationPortRange 80

# 允许 HTTPS 入站(优先级 110)
$nsg | Add-AzNetworkSecurityRuleConfig `
-Name "Allow-HTTPS" `
-Description "允许 HTTPS 流量" `
-Access Allow `
-Protocol Tcp `
-Direction Inbound `
-Priority 110 `
-SourceAddressPrefix "Internet" `
-SourcePortRange "*" `
-DestinationAddressPrefix "10.0.1.0/24" `
-DestinationPortRange 443

# 允许 SSH 仅来自公司 IP 段(优先级 200)
$nsg | Add-AzNetworkSecurityRuleConfig `
-Name "Allow-SSH-Corp" `
-Description "允许公司网段 SSH 访问" `
-Access Allow `
-Protocol Tcp `
-Direction Inbound `
-Priority 200 `
-SourceAddressPrefix "203.0.113.0/24" `
-SourcePortRange "*" `
-DestinationAddressPrefix "10.0.1.0/24" `
-DestinationPortRange 22

# 拒绝所有其他入站流量(优先级 4096)
$nsg | Add-AzNetworkSecurityRuleConfig `
-Name "Deny-All-Inbound" `
-Description "拒绝所有其他入站流量" `
-Access Deny `
-Protocol "*" `
-Direction Inbound `
-Priority 4096 `
-SourceAddressPrefix "*" `
-SourcePortRange "*" `
-DestinationAddressPrefix "*" `
-DestinationPortRange "*"

# 保存 NSG 配置
$nsg | Set-AzNetworkSecurityGroup

# 将 NSG 关联到前端子网
$vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $resourceGroup
Set-AzVirtualNetworkSubnetConfig `
-Name "snet-frontend" `
-VirtualNetwork $vnet `
-AddressPrefix "10.0.1.0/24" `
-NetworkSecurityGroup $nsg

$vnet | Set-AzVirtualNetwork

# 查看生效的安全规则
$nsg.SecurityRules | Select-Object Name, Priority, Access, Direction, `
@{N='Source';E={$_.SourceAddressPrefix}}, `
@{N='DestPort';E={$_.DestinationPortRange}} |
Sort-Object Priority |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
Name             Priority Access Direction Source       DestPort
---- -------- ------ --------- ------ --------
Allow-HTTP 100 Allow Inbound Internet 80
Allow-HTTPS 110 Allow Inbound Internet 443
Allow-SSH-Corp 200 Allow Inbound 203.0.113.0/24 22
Deny-All-Inbound 4096 Deny Inbound * *

NSG 规则按照优先级数字从小到大依次匹配,数字越小优先级越高。一旦匹配到允许或拒绝规则,后续规则不再评估。因此,将最常见的流量规则设为高优先级可以提升网络性能。

建立 VNet 对等连接

在实际项目中,不同团队或不同环境往往使用独立的虚拟网络。VNet 对等连接(Peering)允许两个虚拟网络直接通信,流量在 Azure 骨干网上传输,不经过公共互联网,既安全又高效。

下面的脚本在同一个区域内创建两个虚拟网络,并建立双向对等连接。

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
# 创建第二个虚拟网络
$vnet2Name = "vnet-shared"
$vnet2Prefix = "10.1.0.0/16"
$snet2Name = "snet-shared-services"
$snet2Prefix = "10.1.1.0/24"

# 创建子网配置
$subnet2 = New-AzVirtualNetworkSubnetConfig `
-Name $snet2Name `
-AddressPrefix $snet2Prefix

# 创建第二个虚拟网络
$vnet2 = New-AzVirtualNetwork `
-Name $vnet2Name `
-ResourceGroupName $resourceGroup `
-Location $location `
-AddressPrefix $vnet2Prefix `
-Subnet $subnet2

Write-Host "已创建虚拟网络: $vnet2Name ($vnet2Prefix)"

# 重新获取两个 VNet 的最新对象
$vnet1 = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $resourceGroup
$vnet2 = Get-AzVirtualNetwork -Name $vnet2Name -ResourceGroupName $resourceGroup

# 添加从 vnet-demo 到 vnet-shared 的对等连接
Add-AzVirtualNetworkPeering `
-Name "peer-demo-to-shared" `
-VirtualNetwork $vnet1 `
-RemoteVirtualNetworkId $vnet2.Id `
-AllowVirtualNetworkAccess `
-AllowForwardedTraffic

# 添加从 vnet-shared 到 vnet-demo 的对等连接(双向)
Add-AzVirtualNetworkPeering `
-Name "peer-shared-to-demo" `
-VirtualNetwork $vnet2 `
-RemoteVirtualNetworkId $vnet1.Id `
-AllowVirtualNetworkAccess `
-AllowForwardedTraffic

# 验证对等连接状态
$peerings = Get-AzVirtualNetworkPeering -VirtualNetworkName $vnetName -ResourceGroupName $resourceGroup
foreach ($peering in $peerings) {
Write-Host "对等名称: $($peering.Name)"
Write-Host " 远程 VNet: $($peering.RemoteVirtualNetwork.Id.Split('/')[-1])"
Write-Host " 连接状态: $($peering.PeeringState)"
Write-Host ""
}

执行结果示例:

1
2
3
4
5
6
7
8
9
已创建虚拟网络: vnet-shared (10.1.0.0/16)

对等名称: peer-demo-to-shared
远程 VNet: vnet-shared
连接状态: Connected

对等名称: peer-shared-to-demo
远程 VNet: vnet-demo
连接状态: Connected

对等连接的状态为 Connected 表示双向连接已建立成功。对等连接是双向的,必须分别在两个 VNet 上各添加一条对等配置。如果只添加一端,状态会显示为 Initiated

批量查询网络资源

在大型 Azure 环境中,定期审计网络配置是安全合规的重要环节。下面的脚本演示如何批量查询订阅中的所有虚拟网络、子网和 NSG,并生成一份网络拓扑摘要报告。

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
# 获取当前订阅中所有虚拟网络
$vnets = Get-AzVirtualNetwork

$report = @()
foreach ($vnet in $vnets) {
foreach ($subnet in $vnet.Subnets) {
$nsgName = "无"
if ($subnet.NetworkSecurityGroup) {
$nsgName = $subnet.NetworkSecurityGroup.Id.Split('/')[-1]
}

$row = [PSCustomObject]@{
VNet = $vnet.Name
Location = $vnet.Location
VNetPrefix = ($vnet.AddressSpace.AddressPrefixes -join ", ")
Subnet = $subnet.Name
SubnetPrefix = $subnet.AddressPrefix
NSG = $nsgName
}
$report += $row
}
}

# 按虚拟网络名称排序输出报告
$report | Sort-Object VNet, Subnet | Format-Table -AutoSize

# 统计摘要信息
Write-Host "`n--- 网络资源摘要 ---"
Write-Host "虚拟网络总数: $($vnets.Count)"
Write-Host "子网总数: $(($report).Count)"

# 检查未关联 NSG 的子网(潜在安全风险)
$noNsg = $report | Where-Object { $_.NSG -eq "无" }
if ($noNsg) {
Write-Host "`n[警告] 以下子网未关联网络安全组:" -ForegroundColor Yellow
foreach ($item in $noNsg) {
Write-Host " - $($item.VNet)/$($item.Subnet)" -ForegroundColor Yellow
}
} else {
Write-Host "`n[OK] 所有子网均已关联网络安全组" -ForegroundColor Green
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
VNet       Location VNetPrefix  Subnet          SubnetPrefix NSG
---- -------- ---------- ------ ------------ ---
vnet-demo eastasia 10.0.0.0/16 snet-frontend 10.0.1.0/24 nsg-frontend
vnet-demo eastasia 10.0.0.0/16 snet-backend 10.0.2.0/24
vnet-demo eastasia 10.0.0.0/16 snet-data 10.0.3.0/24
vnet-shared eastasia 10.1.0.0/16 snet-shared-services 10.1.1.0/24

--- 网络资源摘要 ---
虚拟网络总数: 2
子网总数: 4

[警告] 以下子网未关联网络安全组:
- vnet-demo/snet-backend
- vnet-demo/snet-data
- vnet-shared/snet-shared-services

这个审计脚本可以快速发现未配置安全组的子网。在生产环境中,建议将此类脚本集成到定期巡检流程中,或配合 Azure Automation 定时执行。

清理示例资源

完成测试后,及时清理资源可以避免产生不必要的费用。以下命令会删除本文创建的所有资源。

1
2
3
4
5
# 删除资源组及其中的所有资源
Remove-AzResourceGroup -Name $resourceGroup -Force -AsJob

# 查看删除任务状态
Get-Job | Select-Object Name, State

执行结果示例:

1
2
3
Name           State
---- -----
LongRunningJob Completed

-AsJob 参数使删除操作在后台执行,适合清理大量资源时使用。删除资源组是不可逆操作,请确保选择了正确的资源组。

注意事项

  1. 地址空间规划:创建 VNet 后虽然可以添加新的地址空间,但修改已有子网的地址前缀可能导致连接中断。建议在规划阶段预留足够的 IP 空间,考虑未来扩容需求。

  2. NSG 规则优先级:Azure 按优先级数字从小到大匹配规则,范围是 100-4096。建议在规则之间预留间隔(如 100、200、300),方便后续插入新规则而无需调整现有优先级。

  3. 对等连接不可传递:如果 VNet A 与 VNet B 对等,VNet B 与 VNet C 对等,VNet A 和 VNet C 之间不会自动互通。需要三方之间各自建立对等连接,或者使用 Azure 防火墙 / VPN 网关进行路由。

  4. 子网大小限制:Azure 在每个子网中保留 5 个 IP 地址不可使用(前 4 个和最后 1 个)。因此一个 /29 子网最多只有 3 个可用 IP,规划子网大小时需要考虑这一限制。

  5. 跨区域对等延迟:虽然 VNet 对等连接支持跨区域(Global Peering),但跨区域流量会产生额外费用且延迟略高于同区域对等。对延迟敏感的应用建议部署在同一区域。

  6. 幂等性脚本设计:生产环境中的网络管理脚本应具备幂等性——即多次运行结果一致。在创建资源前先用 Get-Az* 检查是否已存在,避免重复创建导致报错。

PowerShell 技能连载 - 类型扩展与格式化

适用于 PowerShell 5.1 及以上版本

背景

PowerShell 的扩展类型系统(Extended Type System,简称 ETS)是它区别于传统 .NET 宿主的核心特性之一。当你对一个 FileInfo 对象调用 .Length 并看到它以 KB、MB 的友好格式输出时,或者当你对 DateTime 对象调用 .DayOfWeek 得到中文星期名时,背后都是 ETS 在起作用。ETS 允许你在不修改原始 .NET 类型定义的前提下,为任何对象动态添加属性和方法,甚至自定义它的显示格式。

这种”无侵入式扩展”在实际运维中非常有用。比如你可能希望每个进程对象额外携带一个”内存占用评分”属性,或者让所有服务器连接对象都自带一个 Test-Port 方法。这些需求如果靠继承或封装来实现会非常繁琐,而 ETS 可以用几行配置就搞定。同时,PowerShell 的格式化系统(Formatting System)与 ETS 紧密配合,决定了对象在控制台上以表格、列表还是宽格式显示,以及显示哪些列、列宽多少、对齐方式如何。

本文将从 Update-TypeData 动态添加属性和方法开始,接着介绍 Update-FormatData 自定义显示格式,最后展示如何将类型扩展和格式化配置持久化为 XML 文件,方便在模块中分发。

基础:使用 Update-TypeData 添加脚本属性

Update-TypeData cmdlet 可以为已存在的 .NET 类型动态注入脚本属性(ScriptProperty)和脚本方法(ScriptMethod)。脚本属性本质上是一段在访问时执行的 PowerShell 脚本,可以基于现有属性计算出新值。下面我们为 System.IO.FileInfo 类型添加一个 SizeCategory 属性,根据文件大小自动归类。

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
# 为 FileInfo 添加 SizeCategory 脚本属性
Update-TypeData -TypeName System.IO.FileInfo -MemberType ScriptProperty `
-MemberName SizeCategory -Value {
$len = $this.Length
if ($len -lt 1KB) {
"微型"
}
elseif ($len -lt 1MB) {
"小型"
}
elseif ($len -lt 100MB) {
"中型"
}
elseif ($len -lt 1GB) {
"大型"
}
else {
"超大"
}
}

# 验证新属性是否生效
$testFiles = @(
"/tmp/mini-note.txt"
"/tmp/report-q1.xlsx"
"/tmp/database-backup.bak"
"/tmp/archive.tar.gz"
)

# 创建测试文件(不同大小)
$testSizes = @(128, 524288, 52428800, 2147483648)
$index = 0

foreach ($filePath in $testFiles) {
$dir = Split-Path -Parent $filePath
if (-not (Test-Path -Path $dir)) {
$null = New-Item -ItemType Directory -Path $dir -Force
}

$size = $testSizes[$index]
$content = [byte[]]::new([math]::Min($size, 1024))
[System.IO.File]::WriteAllBytes($filePath, $content)

# 对于需要更大尺寸的文件,用 Stream 扩展
if ($size -gt 1024) {
$stream = [System.IO.File]::OpenWrite($filePath)
$stream.SetLength($size)
$stream.Close()
}

$index++
}

# 输出结果
foreach ($filePath in $testFiles) {
$file = Get-Item -Path $filePath -ErrorAction SilentlyContinue
if ($file) {
$sizeKB = [math]::Round($file.Length / 1KB, 2)
Write-Output "$($file.Name) | 大小: ${sizeKB} KB | 分类: $($file.SizeCategory)"
}
}

# 清理测试文件
foreach ($filePath in $testFiles) {
Remove-Item -Path $filePath -Force -ErrorAction SilentlyContinue
}

执行结果示例:

1
2
3
4
mini-note.txt | 大小: 0.13 KB | 分类: 微型
report-q1.xlsx | 大小: 512 KB | 分类: 小型
database-backup.bak | 大小: 51200 KB | 分类: 中型
archive.tar.gz | 大小: 2097152 KB | 分类: 超大

这段代码的关键在于 Update-TypeData-Value 参数。它接收一段脚本块,在脚本块内通过 $this 访问当前对象实例。每次访问 .SizeCategory 属性时,PowerShell 都会执行这段脚本并返回结果。注意 -TypeName 必须是完全限定的 .NET 类型名称(包含命名空间),不能使用别名。另外,如果同一个属性已经存在,需要加 -Force 参数才能覆盖,否则会报错。

进阶:添加脚本方法与 CodeMethod

除了属性,ETS 还支持添加脚本方法(ScriptMethod)和代码方法(CodeMethod)。脚本方法与脚本属性类似,用脚本块实现;代码方法则直接引用已编译 .NET 类型中的静态方法,性能更高。下面我们为 System.Diagnostics.Process 添加一个 GetUpTime 脚本方法和一个 SafeKill 方法。

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
# 为 Process 类型添加 GetUpTime 脚本方法
Update-TypeData -TypeName System.Diagnostics.Process `
-MemberType ScriptMethod `
-MemberName GetUpTime -Value {
$startTime = $this.StartTime
$uptime = (Get-Date) - $startTime
$hours = [math]::Floor($uptime.TotalHours)
$minutes = $uptime.Minutes
return "${hours}小时${minutes}分钟"
}

# 为 Process 类型添加 SafeKill 脚本方法(安全关闭)
Update-TypeData -TypeName System.Diagnostics.Process `
-MemberType ScriptMethod `
-MemberName SafeKill -Value {
param(
[int]$GracePeriodSeconds = 5
)

try {
$this.CloseMainWindow()
$this.WaitForExit($GracePeriodSeconds * 1000)

if (-not $this.HasExited) {
$this.Kill()
return "强制终止"
}
return "优雅关闭"
}
catch {
return "操作失败: $($_.Exception.Message)"
}
}

# 演示:获取当前 PowerShell 进程的运行时间
$procs = Get-Process -Id $PID
$uptime = $procs.GetUpTime()
Write-Output "当前进程 (PID: $PID) 已运行: $uptime"

# 演示:获取一组进程的运行时长
$targetNames = @("code", "Terminal", "Finder")
Write-Output ""
Write-Output "进程运行时长统计:"
Write-Output ("-" * 55)

foreach ($name in $targetNames) {
$found = Get-Process -Name $name -ErrorAction SilentlyContinue
if ($found) {
foreach ($p in $found) {
try {
$up = $p.GetUpTime()
$memMB = [math]::Round($p.WorkingSet64 / 1MB, 1)
Write-Output ("{0,-20} PID:{1,-8} 内存:{2,8} MB 运行:{3}" -f `
$p.ProcessName, $p.Id, $memMB, $up)
}
catch {
Write-Output ("{0,-20} PID:{1,-8} (无法获取运行时间)" -f `
$p.ProcessName, $p.Id)
}
}
}
else {
Write-Output ("{0,-20} (未运行)" -f $name)
}
}

执行结果示例:

1
2
3
4
5
6
7
8
当前进程 (PID: 48923) 已运行: 2小时34分钟

进程运行时长统计:
-------------------------------------------------------
code PID:31245 内存: 412.3 MB 运行:8小时12分钟
code PID:31246 内存: 38.7 MB 运行:8小时12分钟
Terminal PID:1024 内存: 156.2 MB 运行:48小时5分钟
Finder PID:245 内存: 289.1 MB 运行:120小时0分钟

这段代码演示了 ScriptMethod 的用法。与 ScriptProperty 不同,ScriptMethod 的脚本块可以接收 param 参数,通过 $this 访问当前对象。SafeKill 方法展示了在脚本方法中调用对象自身的其他方法(如 CloseMainWindowKill)来构建复合操作。值得注意的是,try/catch 在脚本方法中同样有效,可以用来处理进程已退出、权限不足等异常情况。在 Windows 系统上获取进程的 StartTime 需要管理员权限,部分系统进程可能会抛出”拒绝访问”异常。

实战:自定义格式化视图并持久化

类型扩展让你的对象拥有了新属性,但默认情况下 PowerShell 并不知道该如何展示它们。Update-FormatData 可以自定义对象的默认显示格式,而将类型定义和格式定义保存为 XML 文件则能实现跨会话持久化。下面我们创建一个自定义的 ServerStatus 对象类型,为它添加格式化视图,并将所有配置保存为可复用的模块文件。

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
# 定义自定义对象类型名称
$TypeName = "MyTools.ServerStatus"

# 创建带类型名的自定义对象
function New-ServerStatus {
param(
[string]$Name,
[string]$IPAddress,
[string]$Status,
[int]$ResponseMs,
[string]$OS
)

$obj = [PSCustomObject]@{
Name = $Name
IPAddress = $IPAddress
Status = $Status
ResponseMs = $ResponseMs
OS = $OS
CheckedAt = Get-Date
}

$obj.PSTypeNames.Insert(0, $TypeName)
return $obj
}

# 使用 Update-TypeData 为自定义类型添加脚本属性
Update-TypeData -TypeName $TypeName -MemberType ScriptProperty `
-MemberName UptimeLabel -Value {
$ts = (Get-Date) - $this.CheckedAt
return "上次检查于 {0:N0} 秒前" -f $ts.TotalSeconds
} -Force

# 创建测试数据
$servers = @(
New-ServerStatus -Name "WEB-01" -IPAddress "10.0.1.10" `
-Status "在线" -ResponseMs 12 -OS "Windows Server 2022"
New-ServerStatus -Name "WEB-02" -IPAddress "10.0.1.11" `
-Status "在线" -ResponseMs 18 -OS "Windows Server 2022"
New-ServerStatus -Name "DB-01" -IPAddress "10.0.2.20" `
-Status "告警" -ResponseMs 245 -OS "Ubuntu 22.04"
New-ServerStatus -Name "CACHE-01" -IPAddress "10.0.3.30" `
-Status "离线" -ResponseMs -1 -OS "CentOS 7"
New-ServerStatus -Name "API-01" -IPAddress "10.0.4.40" `
-Status "在线" -ResponseMs 8 -OS "Windows Server 2025"
)

# 默认输出(无格式化,属性多时显示为列表)
Write-Output "=== 默认输出(List 格式)==="
$servers | Select-Object -First 1 | Format-List

# 使用 Format-Table 手动选择列
Write-Output ""
Write-Output "=== 自定义表格输出 ==="
$tableOutput = foreach ($svr in $servers) {
$color = switch ($svr.Status) {
"在线" { "Green" }
"告警" { "Yellow" }
"离线" { "Red" }
default { "White" }
}

[PSCustomObject]@{
名称 = $svr.Name
地址 = $svr.IPAddress
状态 = $svr.Status
响应时间 = if ($svr.ResponseMs -ge 0) { "$($svr.ResponseMs) ms" } else { "N/A" }
操作系统 = $svr.OS
}
}
$tableOutput | Format-Table -AutoSize

# 演示脚本属性
Write-Output ""
Write-Output "=== 脚本属性 UptimeLabel ==="
foreach ($svr in $servers) {
Write-Output " $($svr.Name): $($svr.UptimeLabel)"
}

# 导出类型定义到 XML(供模块加载使用)
$typeXmlPath = "/tmp/MyTools.Types.ps1xml"
$formatXmlPath = "/tmp/MyTools.Format.ps1xml"

# 生成类型 XML
$typeXmlContent = @"
<?xml version="1.0" encoding="utf-8"?>
<Types>
<Type>
<Name>MyTools.ServerStatus</Name>
<Members>
<ScriptProperty>
<Name>UptimeLabel</Name>
<GetScriptBlock>
`$ts = (Get-Date) - `$this.CheckedAt
"上次检查于 {0:N0} 秒前" -f `$ts.TotalSeconds
</GetScriptBlock>
</ScriptProperty>
<ScriptProperty>
<Name>ResponseGrade</Name>
<GetScriptBlock>
`$ms = `$this.ResponseMs
if (`$ms -lt 0) { return "不可达" }
if (`$ms -lt 50) { return "优秀" }
if (`$ms -lt 200) { return "良好" }
return "缓慢"
</GetScriptBlock>
</ScriptProperty>
</Members>
</Type>
</Types>
"@

Set-Content -Path $typeXmlPath -Value $typeXmlContent -Encoding UTF8
Write-Output ""
Write-Output "类型定义已导出: $typeXmlPath"
Write-Output "在模块中使用: Update-TypeData -AppendPath `"$typeXmlPath`""

执行结果示例:

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
=== 默认输出(List 格式)===

Name : WEB-01
IPAddress : 10.0.1.10
Status : 在线
ResponseMs : 12
OS : Windows Server 2022
CheckedAt : 2025/12/15 10:30:45

=== 自定义表格输出 ===

名称 地址 状态 响应时间 操作系统
---- ---- ---- -------- --------
WEB-01 10.0.1.10 在线 12 ms Windows Server 2022
WEB-02 10.0.1.11 在线 18 ms Windows Server 2022
DB-01 10.0.2.20 告警 245 ms Ubuntu 22.04
CACHE-01 10.0.3.30 离线 N/A CentOS 7
API-01 10.0.4.40 在线 8 ms Windows Server 2025

=== 脚本属性 UptimeLabel ===
WEB-01: 上次检查于 2 秒前
WEB-02: 上次检查于 2 秒前
DB-01: 上次检查于 2 秒前
CACHE-01: 上次检查于 2 秒前
API-01: 上次检查于 2 秒前

类型定义已导出: /tmp/MyTools.Types.ps1xml
在模块中使用: Update-TypeData -AppendPath "/tmp/MyTools.Types.ps1xml"

这段代码涵盖了 ETS 的完整工作流。首先通过 $obj.PSTypeNames.Insert(0, $TypeName) 将自定义类型名插入到对象的类型层次链头部,这样 PowerShell 就能识别并应用对应的类型扩展。Update-TypeData 为该类型添加了 UptimeLabel 脚本属性。最后展示了如何将类型定义导出为 .ps1xml 文件——这种 XML 格式是 PowerShell 模块分发类型扩展的标准方式,模块清单(.psd1)中的 TypesToProcess 字段可以声明这些文件,模块加载时自动注册。

注意事项

  1. Update-TypeData 在修改类型后对已存在的对象不一定立即生效。某些情况下需要重新创建对象实例或重新获取对象(例如再次调用 Get-Process)才能看到新添加的成员。如果发现新属性不可见,可以尝试先用 Remove-TypeData 清除再重新添加。

  2. .ps1xml 文件不会自动加载,必须显式注册。独立脚本中使用 Update-TypeData -AppendPath 加载,模块中则通过 .psd1 清单文件的 TypesToProcessFormatsToProcess 字段声明。如果模块有嵌套模块,确保 .ps1xml 文件路径正确——推荐使用相对于模块根目录的相对路径。

  3. 脚本属性和方法中的 $this 指向当前对象实例,但脚本块在独立的闭包中执行,无法直接访问外层作用域的变量。如果需要传入外部数据,可以使用 GetNewClosure() 方法捕获变量,或者将数据存储在对象本身的 NoteProperty 中再在脚本块内通过 $this 访问。

  4. PSTypeNames 的顺序决定类型解析优先级Insert(0, ...) 将自定义类型名放在最前面,PowerShell 会优先匹配第一个类型名对应的类型扩展和格式定义。如果多个模块为同一 .NET 类型注册了不同的扩展,后加载的模块可能覆盖先前的定义,建议为自定义类型使用带命名空间前缀的唯一名称(如 MyCompany.MyModule.MyType)。

  5. 格式化 XML 中不要嵌入复杂的条件逻辑.Format.ps1xml 支持通过 <Condition><ScriptBlock> 实现条件显示,但调试困难且性能较差。复杂的格式化需求建议在脚本中用 Format-Table/Format-List 配合计算属性(@{Name=...; Expression=...})来实现,既灵活又易于排错。

  6. Update-TypeData -Force 会静默覆盖已有成员。在生产环境使用前务必确认不会与 PowerShell 内置类型扩展或其他模块的扩展产生冲突。可以用 Get-TypeData -TypeName <名称> 先查看当前已注册的类型扩展,检查是否存在同名成员。

PowerShell 技能连载 - Azure 负载均衡器管理

适用于 PowerShell 5.1 及以上版本

在云原生架构中,负载均衡器是保障服务高可用的核心组件。Azure 负载均衡器(Azure Load Balancer)作为 L4 层负载均衡服务,能够在多个虚拟机或实例之间分发入站和出站流量,确保应用在高峰期依然稳定运行。无论是面向互联网的公共服务,还是内部微服务间的通信,负载均衡器都扮演着不可替代的角色。

对于运维工程师而言,手动在 Azure 门户中配置负载均衡器不仅耗时,而且容易出错。通过 PowerShell 的 Az 模块,我们可以将负载均衡器的创建、规则配置、健康探测以及后端池管理全部自动化。这种方式不仅提升了效率,还能将配置纳入版本控制,实现基础设施即代码(IaC)的最佳实践。

本文将围绕 Azure 负载均衡器的日常管理场景,介绍如何使用 PowerShell 完成从创建到监控的完整操作流程,帮助你构建可重复、可审计的负载均衡器管理方案。

创建负载均衡器及其前端 IP 配置

在创建负载均衡器之前,需要先准备好资源组和公共 IP 地址。以下代码展示了如何通过 PowerShell 一步到位地创建一个标准 SKU 的负载均衡器,并配置前端 IP 池和后端地址池。

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
# 定义变量
$rgName = "myResourceGroup"
$location = "EastAsia"
$publicIpName = "myLoadBalancerPublicIP"
$lbName = "myLoadBalancer"

# 创建资源组
New-AzResourceGroup -Name $rgName -Location $location

# 创建公共 IP 地址
$publicIp = New-AzPublicIpAddress `
-ResourceGroupName $rgName `
-Name $publicIpName `
-Location $location `
-AllocationMethod Static `
-Sku Standard

# 创建前端 IP 配置
$frontendIp = New-AzLoadBalancerFrontendIpConfig `
-Name "myFrontendPool" `
-PublicIpAddress $publicIp

# 创建后端地址池
$backendPool = New-AzLoadBalancerBackendAddressPoolConfig `
-Name "myBackendPool"

# 创建负载均衡器
$lb = New-AzLoadBalancer `
-ResourceGroupName $rgName `
-Name $lbName `
-Location $location `
-Sku Standard `
-FrontendIpConfiguration $frontendIp `
-BackendAddressPool $backendPool

Write-Host "负载均衡器 '$lbName' 创建完成"
Write-Host "前端 IP: $($publicIp.IpAddress)"

执行结果示例:

1
2
3
4
5
6
7
8
ResourceGroupName : myResourceGroup
Location : eastasia
ProvisioningState : Succeeded
Sku : Standard
Name : myLoadBalancer

负载均衡器 'myLoadBalancer' 创建完成
前端 IP: 20.205.12.34

创建完成后,负载均衡器还只是一个空壳。接下来需要添加健康探测和负载均衡规则,才能真正开始分发流量。

配置健康探测与负载均衡规则

健康探测(Health Probe)用于定期检测后端实例的可用性。只有通过探测的实例才会接收流量。负载均衡规则则定义了前端端口到后端端口的映射关系以及分发策略。

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
$rgName = "myResourceGroup"
$lbName = "myLoadBalancer"
$lb = Get-AzLoadBalancer -ResourceGroupName $rgName -Name $lbName

# 添加 HTTP 健康探测
$probe = Add-AzLoadBalancerProbeConfig `
-LoadBalancer $lb `
-Name "httpProbe" `
-Protocol Http `
-Port 80 `
-RequestPath "/" `
-IntervalInSeconds 15 `
-ProbeCount 2

# 添加 TCP 负载均衡规则(将前端 80 端口映射到后端 80 端口)
$rule = Add-AzLoadBalancerRuleConfig `
-LoadBalancer $lb `
-Name "httpRule" `
-Protocol Tcp `
-FrontendPort 80 `
-BackendPort 80 `
-FrontendIpConfiguration $lb.FrontendIpConfigurations[0] `
-BackendAddressPool $lb.BackendAddressPools[0] `
-Probe $lb.Probes[0] `
-LoadDistribution Default `
-IdleTimeoutInMinutes 4 `
-EnableFloatingIp $false

# 将配置写入 Azure
Set-AzLoadBalancer -LoadBalancer $lb

Write-Host "健康探测和负载均衡规则配置完成"

执行结果示例:

1
2
3
4
5
6
7
Name              : myLoadBalancer
ResourceGroupName : myResourceGroup
Probes : {httpProbe}
LoadBalancingRules: {httpRule}
ProvisioningState : Succeeded

健康探测和负载均衡规则配置完成

这里有几个关键参数值得注意:IntervalInSeconds 设置为 15 秒表示每 15 秒探测一次;ProbeCount 设为 2 意味着连续两次探测失败后才会将实例标记为不健康。LoadDistribution 使用 Default 表示五元组哈希分发策略,适合大多数 Web 场景。

管理后端池与监控实例健康状态

后端池的管理是日常运维中最常见的操作。下面展示如何将虚拟机添加到后端池,以及如何查询各实例的健康状态。

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
$rgName = "myResourceGroup"
$lbName = "myLoadBalancer"
$lb = Get-AzLoadBalancer -ResourceGroupName $rgName -Name $lbName

# 获取要加入后端池的虚拟机网络接口
$vmNames = @("myVM01", "myVM02", "myVM03")
$backendPool = $lb.BackendAddressPools[0]

foreach ($vmName in $vmNames) {
$nic = Get-AzNetworkInterface |
Where-Object { $_.VirtualMachine.Id -like "*$vmName*" }

if ($nic) {
$nic.IpConfigurations[0].LoadBalancerBackendAddressPools = $backendPool
Set-AzNetworkInterface -NetworkInterface $nic
Write-Host "已将 '$vmName' 加入后端池"
} else {
Write-Host "警告: 未找到虚拟机 '$vmName' 的网络接口" -ForegroundColor Yellow
}
}

# 查询负载均衡器后端健康状态
$health = Get-AzLoadBalancerBackendAddressPool `
-ResourceGroupName $rgName `
-LoadBalancerName $lbName `
-Name "myBackendPool"

Write-Host "`n后端池当前配置:"
Write-Host "名称: $($health.Name)"
Write-Host "关联的网络接口数: $($health.NetworkInterfaces.Count)"

执行结果示例:

1
2
3
4
5
6
7
已将 'myVM01' 加入后端池
已将 'myVM02' 加入后端池
已将 'myVM03' 加入后端池

后端池当前配置:
名称: myBackendPool
关联的网络接口数: 3

通过 foreach 循环批量操作,可以轻松管理后端池中的实例。在生产环境中,建议将这段逻辑封装为可参数化的函数,方便在扩容或缩容时快速调用。

查询负载均衡器指标与运行状态

除了配置管理,监控负载均衡器的运行指标同样重要。以下代码通过 Get-AzMetric cmdlet 查询负载均衡器的流量指标,帮助你了解流量分布是否均匀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$rgName = "myResourceGroup"
$lbName = "myLoadBalancer"

$lb = Get-AzLoadBalancer -ResourceGroupName $rgName -Name $lbName

# 查询过去 1 小时的 SYN 计数(新建连接数)指标
$endTime = Get-Date
$startTime = $endTime.AddHours(-1)

$metrics = Get-AzMetric `
-ResourceId $lb.Id `
-MetricName "SynCount" `
-StartTime $startTime `
-EndTime $endTime `
-TimeGrain 00:05:00 `
-AggregationType Total

Write-Host "负载均衡器 SYN 计数(过去 1 小时,每 5 分钟采样):"
Write-Host ("{0,-25} {1,15}" -f "时间", "SYN 计数")
Write-Host ("{0,-25} {1,15}" -f "----", "--------")

foreach ($ts in $metrics.Data) {
$timeStr = $ts.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss")
$synCount = if ($ts.Total) { [math]::Round($ts.Total, 0) } else { 0 }
Write-Host ("{0,-25} {1,15}" -f $timeStr, $synCount)
}

# 获取 NAT 规则列表
$lb = Get-AzLoadBalancer -ResourceGroupName $rgName -Name $lbName
$inboundNatRules = $lb.InboundNatRules

if ($inboundNatRules) {
Write-Host "`n入站 NAT 规则:"
foreach ($rule in $inboundNatRules) {
Write-Host " 规则名称: $($rule.Name)"
Write-Host " 前端端口: $($rule.FrontendPort) -> 后端端口: $($rule.BackendPort)"
Write-Host " 协议: $($rule.Protocol)"
Write-Host ""
}
} else {
Write-Host "`n当前未配置入站 NAT 规则"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
负载均衡器 SYN 计数(过去 1 小时,每 5 分钟采样):
时间 SYN 计数
---- --------
2025-12-12 07:00:00 142
2025-12-12 07:05:00 158
2025-12-12 07:10:00 163
2025-12-12 07:15:00 151
2025-12-12 07:20:00 147
2025-12-12 07:25:00 172
2025-12-12 07:30:00 189
2025-12-12 07:35:00 195
2025-12-12 07:40:00 210
2025-12-12 07:45:00 198
2025-12-12 07:50:00 183
2025-12-12 07:55:00 176

当前未配置入站 NAT 规则

通过定期采集这些指标数据,你可以建立流量基线,在流量异常时及时发现并响应。结合 Azure Monitor 告警规则,还可以实现自动化的运维通知。

注意事项

  1. SKU 选择:Azure 负载均衡器有 Basic 和 Standard 两种 SKU。生产环境务必使用 Standard SKU,它支持可用性区域、更高的 SLA(99.99%)以及更精细的安全控制。Basic SKU 已不推荐用于生产环境。

  2. 健康探测参数调优IntervalInSecondsProbeCount 的组合决定了故障检测的灵敏度。过于激进的配置(如间隔 5 秒、计数 1)可能导致瞬时抖动误判;过于保守的配置(如间隔 30 秒、计数 5)则会让故障实例长时间接收流量。建议从 15 秒间隔、2 次失败阈值开始,根据业务特点调整。

  3. 负载分发策略LoadDistribution 参数有三种取值:Default(五元组哈希)、SourceIP(二元组哈希)、SourceIPProtocol(三元组哈希)。对于需要会话保持的场景(如 WebSocket 长连接),考虑使用 SourceIPProtocol;对于一般 Web 流量,Default 即可。

  4. 后端池操作幂等性:修改后端池配置时,Set-AzLoadBalancer 会整体替换配置。务必先 Get-AzLoadBalancer 获取最新状态,在现有对象上修改后再写回,避免覆盖其他并发的配置变更。

  5. 公共 IP 与 SKU 匹配:Standard SKU 的负载均衡器必须搭配 Standard SKU 的公共 IP 地址。如果尝试使用 Basic SKU 的公共 IP,创建时会报错。这一点在脚本变量初始化阶段就要确保一致。

  6. 权限与模块版本:运行本文中的脚本需要安装 Az.Network 模块(4.0 或更高版本),且当前账户需要在资源组上拥有 Network Contributor 或更高权限。使用 Get-InstalledModule -Name Az.Network 检查模块版本,必要时通过 Update-Module Az.Network 升级。

PowerShell 技能连载 - .NET 互操作深入

适用于 PowerShell 7.0 及以上版本

PowerShell 本质上是 .NET 生态的一部分,每一个变量、每一个对象都运行在 CLR 之上。虽然日常工作中我们习惯使用 cmdlet 来完成任务,但在面对高性能计算、底层系统调用或需要精确控制内存的场景时,直接使用 .NET 类往往能获得数量级的性能提升。

理解 .NET 互操作不仅是突破 cmdlet 限制的手段,更是深入理解 PowerShell 运行时行为的钥匙。当你知道 [System.IO.File]::ReadAllText() 比同等的 Get-Content 快数十倍时,你就能够在脚本中做出更明智的选择——何时追求简洁,何时追求性能。

本文将从三个维度展开:直接调用 .NET 类实现高性能操作、利用反射动态调用方法和创建实例、以及通过 P/Invoke 调用原生 Win32 API,帮助你全面掌握 PowerShell 中的 .NET 互操作能力。

直接调用 .NET 类——高性能替代方案

PowerShell 的 cmdlet 设计强调易用性和管道友好性,但这通常会引入额外的开销。当你处理大量数据或需要极致性能时,直接使用 .NET 类是更优的选择。

以下示例展示了几个常见的场景:使用 System.IO 进行高性能文件操作,使用 System.Text.Json 进行快速 JSON 序列化,以及使用 System.Net.Http 发起 HTTP 请求。

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
# 场景一:高性能文件读写
# 使用 [System.IO.File] 替代 Get-Content,性能提升数十倍
$testFile = [System.IO.Path]::GetTempFileName()
$lines = 1..10000 | ForEach-Object { "Line $_ at $(Get-Date -Format 'HH:mm:ss.fff')" }

# 直接写入,比 Set-Content 快得多
[System.IO.File]::WriteAllLines($testFile, $lines)

# 读取全部内容为单个字符串
$content = [System.IO.File]::ReadAllText($testFile)
Write-Host "文件大小: $([System.IO.FileInfo]::new($testFile).Length) 字节"

# 按行读取(延迟枚举,内存友好)
$lineCount = 0
$reader = [System.IO.StreamReader]::new($testFile)
while ($null -ne $reader.ReadLine()) { $lineCount++ }
$reader.Close()
Write-Host "总行数: $lineCount"

# 场景二:System.Text.Json 高性能序列化
$product = [ordered]@{
Name = "PowerShell 7.4"
Version = "7.4.0"
Features = @("Pipeline Chain", "Null Coalescing", "Ternary Operator")
ReleaseDate = "2023-11-16"
IsLTS = $true
}

# 使用 System.Text.Json(比 ConvertTo-Json 更快)
$jsonOptions = [System.Text.Json.JsonSerializerOptions]::new()
$jsonOptions.WriteIndented = $true
$jsonOptions.PropertyNamingPolicy = [System.Text.Json.JsonNamingPolicy]::CamelCase

$json = [System.Text.Json.JsonSerializer]::Serialize(
[System.Collections.Generic.Dictionary[string, object]]$product,
$jsonOptions
)
Write-Host $json

# 反序列化
$deserialized = [System.Text.Json.JsonSerializer]::Deserialize(
$json,
[System.Collections.Generic.Dictionary[string, object]].MakeGenericType(@([string], [object])),
$jsonOptions
)
Write-Host "反序列化结果 - Name: $($deserialized['name'])"

# 场景三:使用 System.Net.Http 发起请求(比 Invoke-WebRequest 更灵活)
$httpClient = [System.Net.Http.HttpClient]::new()
$httpClient.Timeout = [System.TimeSpan]::FromSeconds(10)
$httpClient.DefaultRequestHeaders.Add("User-Agent", "PowerShell-DotNetInterop/1.0")

try {
$response = $httpClient.GetAsync("https://httpbin.org/get").GetAwaiter().GetResult()
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
Write-Host "状态码: $($response.StatusCode)"
Write-Host "响应长度: $($body.Length) 字符"
}
finally {
$httpClient.Dispose()
}

# 清理临时文件
[System.IO.File]::Delete($testFile)

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
文件大小: 358896 字节
总行数: 10000
{
"name": "PowerShell 7.4",
"version": "7.4.0",
"features": [
"Pipeline Chain",
"Null Coalescing",
"Ternary Operator"
],
"releaseDate": "2023-11-16",
"isLTS": true
}
反序列化结果 - Name: PowerShell 7.4
状态码: OK
响应长度: 358 字符

使用反射动态调用方法和创建实例

反射是 .NET 的核心能力之一,它允许你在运行时检查类型信息、动态调用方法、创建实例,甚至修改私有字段。在 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
# 演示:使用反射探索和调用 .NET 类型

# 获取 System.String 的所有公共方法
$stringType = [string]
$methods = $stringType.GetMethods([System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::Instance)
Write-Host "String 类型共有 $($methods.Count) 个公共方法"
Write-Host "部分方法列表:"
$methods | Select-Object -First 8 Name, ReturnType | Format-Table -AutoSize

# 使用反射动态调用方法
$text = "Hello, PowerShell .NET Interop!"
$methodInfo = $stringType.GetMethod("Contains", [type[]]@([string]))
$result = $methodInfo.Invoke($text, [object[]]@("PowerShell"))
Write-Host "Contains('PowerShell'): $result"

# 动态创建泛型集合
$listType = [System.Collections.Generic.List`1].MakeGenericType([int])
$list = [Activator]::CreateInstance($listType)
$addMethod = $listType.GetMethod("Add")
1..5 | ForEach-Object { $addMethod.Invoke($list, [object[]]$_) }
Write-Host "动态创建的 List<int> 内容: $($list -join ', ')"

# 反射加载外部程序集并调用其中的类型
# 模拟:加载 System.Text.RegularExpressions 并使用反射调用
$asm = [System.Reflection.Assembly]::LoadWithPartialName("System.Text.RegularExpressions")
$regexType = $asm.GetType("System.Text.RegularExpressions.Regex")
$pattern = "\b\w+@\w+\.\w+\b"

# 通过反射创建 Regex 实例
$regexCtor = $regexType.GetConstructor([type[]]@([string]))
$regex = $regexCtor.Invoke([object[]]@($pattern))

# 调用 Matches 方法
$matchesMethod = $regexType.GetMethod("Matches", [type[]]@([string]))
$sampleText = "联系我们: admin@vichamp.com 或 support@example.org"
$matches = $matchesMethod.Invoke($regex, [object[]]@($sampleText))

Write-Host "在文本中找到 $($matches.Count) 个邮箱地址:"
foreach ($m in $matches) {
Write-Host " - $($m.Value) (位置: $($m.Index))"
}

# 使用反射访问静态方法和属性
$env = [System.Environment]
$props = $env.GetProperties([System.Reflection.BindingFlags]::Static -bor [System.Reflection.BindingFlags]::Public)
Write-Host "`nSystem.Environment 静态属性:"
$props | Select-Object -First 5 Name, PropertyType | ForEach-Object {
$val = $env.GetProperty($_.Name).GetValue($null)
Write-Host " $($_.Name) = $val"
}

执行结果示例:

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
String 类型共有 108 个公共方法
部分方法列表:
Name ReturnType
---- ----------
CompareTo System.Int32
Contains System.Boolean
CopyTo System.Void
EndsWith System.Boolean
Equals System.Boolean
Equals System.Boolean
Equals System.Boolean
GetEnumerator System.CharEnumerator

Contains('PowerShell'): True
动态创建的 List<int> 内容: 1, 2, 3, 4, 5
在文本中找到 2 个邮箱地址:
- admin@vichamp.com (位置: 5)
- support@example.org (位置: 28)

System.Environment 静态属性:
CommandLine = /opt/powershell/pwsh
CurrentDirectory = /home/user/scripts
ExitCode = 0
HasShutdownStarted = False
Is64BitProcess = True

P/Invoke 调用 Win32 API

P/Invoke(Platform Invocation Services)是 .NET 提供的调用非托管 DLL 函数的机制。在 PowerShell 中,你可以通过 Add-Type 定义签名,然后直接调用 Windows 系统 DLL 中的原生函数。这在需要与操作系统底层交互时非常有用,例如获取系统信息、操作窗口句柄或访问注册表的底层 API。

以下示例展示了几种常见的 Win32 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
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
# P/Invoke 调用 Win32 API 示例
# 注意:以下代码仅在 Windows 平台上运行

if ($IsWindows -or ($env:OS -eq "Windows_NT")) {

# 示例一:获取系统内存信息
$memorySignature = @"
using System;
using System.Runtime.InteropServices;

public class MemoryHelper
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public class MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;

public MEMORYSTATUSEX()
{
this.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GlobalMemoryStatusEx(
[In, Out] MEMORYSTATUSEX lpBuffer);
}
"@

Add-Type -TypeDefinition $memorySignature -Language CSharp

$memStatus = [MemoryHelper+MEMORYSTATUSEX]::new()
[MemoryHelper]::GlobalMemoryStatusEx($memStatus) | Out-Null

$totalGB = [math]::Round($memStatus.ullTotalPhys / 1GB, 2)
$availGB = [math]::Round($memStatus.ullAvailPhys / 1GB, 2)
$usedPercent = $memStatus.dwMemoryLoad

Write-Host "=== 系统内存信息 ==="
Write-Host "总物理内存: ${totalGB} GB"
Write-Host "可用物理内存: ${availGB} GB"
Write-Host "内存使用率: ${usedPercent}%"

# 示例二:获取精确的系统启动时间(比 Get-Date 更准确)
$uptimeSignature = @"
using System;
using System.Runtime.InteropServices;

public class UptimeHelper
{
[DllImport("kernel32.dll")]
public static extern UInt64 GetTickCount64();
}
"@

Add-Type -TypeDefinition $uptimeSignature -Language CSharp

$tickCount = [UptimeHelper]::GetTickCount64()
$uptime = [TimeSpan]::FromMilliseconds($tickCount)
Write-Host "`n=== 系统运行时间 ==="
Write-Host "已运行: $($uptime.Days) 天 $($uptime.Hours) 小时 $($uptime.Minutes) 分钟"

# 示例三:获取当前控制台窗口标题
$consoleSignature = @"
using System;
using System.Text;
using System.Runtime.InteropServices;

public class ConsoleHelper
{
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern int GetConsoleTitle(
StringBuilder lpConsoleTitle,
int nSize);
}
"@

Add-Type -TypeDefinition $consoleSignature -Language CSharp

$titleBuilder = [System.Text.StringBuilder]::new(256)
[ConsoleHelper]::GetConsoleTitle($titleBuilder, 256) | Out-Null
Write-Host "`n=== 控制台信息 ==="
Write-Host "当前窗口标题: $($titleBuilder.ToString())"

} else {
Write-Host "P/Invoke 示例需要在 Windows 平台上运行"
Write-Host "当前平台: $($PSVersionTable.OS)"

# 跨平台替代方案:使用 .NET API 获取系统信息
Write-Host "`n=== 跨平台系统信息 ==="
Write-Host "机器名: $([System.Environment]::MachineName)"
Write-Host "用户名: $([System.Environment]::UserName)"
Write-Host "处理器核心数: $([System.Environment]::ProcessorCount)"
Write-Host "系统目录: $([System.Environment]::SystemDirectory)"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
=== 系统内存信息 ===
总物理内存: 32.00 GB
可用物理内存: 14.37 GB
内存使用率: 55%

=== 系统运行时间 ===
已运行: 3 天 7 小时 42 分钟

=== 控制台信息 ===
当前窗口标题: Windows PowerShell

注意事项

  1. 性能权衡:直接使用 .NET 类虽然性能更高,但代码可读性通常不如 cmdlet。对于一次性脚本或管理任务,优先使用 cmdlet;对于需要处理大量数据或高频调用的场景,再考虑直接使用 .NET 类。可以用 Measure-Command 对比实际性能差异后再做决策。

  2. 类型转换陷阱:PowerShell 会自动包装 .NET 对象,某些情况下类型转换可能不如预期。例如 [System.Collections.Generic.List[int]] 和 PowerShell 数组之间存在隐式转换,但频繁转换会抵消性能优势。建议尽量在 .NET API 之间传递数据,减少跨越 PowerShell 管道的次数。

  3. 反射的性能开销:反射调用比直接调用慢 10-100 倍。如果需要大量调用同一方法,应该缓存 MethodInfo 对象并重复使用,而不是每次都重新查找。在高性能循环中,应避免使用反射。

  4. P/Invoke 平台限制:P/Invoke 调用的 Win32 API 仅在 Windows 上可用。如果你的脚本需要跨平台运行,务必添加平台检测逻辑(如 $IsWindows),并提供基于 .NET API 的跨平台替代方案。同时要注意区分 x86 和 x64 的函数签名差异。

  5. 程序集加载策略Add-Type 编译的 C# 代码会生成临时程序集,无法卸载。大量使用 Add-Type 会导致内存增长。对于复杂场景,建议将 C# 代码编译为独立的 DLL,使用 [System.Reflection.Assembly]::LoadFrom() 按需加载。

  6. Dispose 模式:许多 .NET 类(如 HttpClientStreamReaderFileStream)实现了 IDisposable 接口。在 PowerShell 中应使用 try/finally 块或 .Dispose() 方法显式释放资源,避免因垃圾回收延迟导致的文件锁或连接泄漏。

PowerShell 技能连载 - 事件注册与处理

适用于 PowerShell 5.1 及以上版本

背景

在自动化运维场景中,很多操作并不是”调用-等待-返回”的同步模式。文件系统的变动、WMI 对象的状态变更、进程的启动与退出——这些事情发生的时间不可预测。如果用轮询(polling)的方式去反复检查,不仅浪费 CPU 资源,还会引入检测延迟。PowerShell 提供了一套事件订阅机制,允许你注册对特定事件的关注,当事件触发时自动执行预定义的处理逻辑,实现”被动响应”而非”主动轮询”的编程模型。

PowerShell 的事件系统围绕三个核心 cmdlet 展开:Register-EngineEvent 用于注册 PowerShell 引擎自身发出的事件(例如自定义的 .NET 事件);Register-ObjectEvent 用于订阅 .NET 对象的事件(例如 FileSystemWatcher 的文件变更事件);Register-WmiEvent 用于订阅 WMI 事件(例如进程创建)。配合 Get-EventRemove-EventUnregister-Event,构成了完整的事件生命周期管理能力。

本文将围绕 Register-EngineEventRegister-ObjectEvent,通过三个递进的场景,演示如何在实际工作中使用 PowerShell 的事件注册与处理机制。

基础:使用 Register-EngineEvent 构建自定义事件

Register-EngineEvent 是 PowerShell 事件体系中最简单直接的入口。它订阅由 PowerShell 引擎发出的 Eventing 事件,最常用的场景是通过 New-Event 手动触发自定义事件,再由注册的 Action 脚本块来响应。这种模式非常适合在模块或脚本内部实现松耦合的消息通信——事件的发送方不需要知道谁在监听,接收方也不需要知道谁在发送。

下面的示例模拟了一个批量任务处理流程:主循环在处理每批任务后发出一个自定义事件,事件携带批次的处理结果;独立的监听器捕获事件并记录日志。

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
# 步骤 1:注册自定义引擎事件,监听 "BatchCompleted" 事件
$action = {
$eventData = $Event.SourceEventArgs
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] 批次事件: $($eventData.BatchName) | " +
"成功: $($eventData.SuccessCount) | 失败: $($eventData.FailCount) | " +
"耗时: $($eventData.Duration)ms"
Write-Output $logEntry
}

Register-EngineEvent -SourceIdentifier "BatchCompleted" -Action $action | Out-Null

# 步骤 2:定义批次数据并逐批处理
$batches = @(
@{ Name = "用户导入-第1批"; Count = 150 }
@{ Name = "用户导入-第2批"; Count = 230 }
@{ Name = "用户导入-第3批"; Count = 180 }
@{ Name = "用户导入-第4批"; Count = 310 }
@{ Name = "用户导入-第5批"; Count = 95 }
)

foreach ($batch in $batches) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()

# 模拟处理:随机决定成功和失败的数量
$failCount = Get-Random -Minimum 0 -Maximum ([math]::Min(5, $batch.Count))
$successCount = $batch.Count - $failCount

# 模拟处理耗时
Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 200)
$sw.Stop()

# 通过 New-Event 触发自定义事件
$eventArgs = [PSCustomObject]@{
BatchName = $batch.Name
SuccessCount = $successCount
FailCount = $failCount
Duration = $sw.ElapsedMilliseconds
}

New-Event -SourceIdentifier "BatchCompleted" -Sender $null -EventArguments $eventArgs | Out-Null
}

# 步骤 3:清理事件注册
Unregister-Event -SourceIdentifier "BatchCompleted"

Write-Output ""
Write-Output "所有批次处理完毕"

执行结果示例:

1
2
3
4
5
6
7
[2025-12-10 10:23:15] 批次事件: 用户导入-第1批 | 成功: 149 | 失败: 1 | 耗时: 127ms
[2025-12-10 10:23:15] 批次事件: 用户导入-第2批 | 成功: 228 | 失败: 2 | 耗时: 153ms
[2025-12-10 10:23:15] 批次事件: 用户导入-第3批 | 成功: 178 | 失败: 2 | 耗时: 89ms
[2025-12-10 10:23:15] 批次事件: 用户导入-第4批 | 成功: 308 | 失败: 2 | 耗时: 142ms
[2025-12-10 10:23:15] 批次事件: 用户导入-第5批 | 成功: 92 | 失败: 3 | 耗时: 67ms

所有批次处理完毕

这段代码的核心思路是”生产者-消费者”解耦。主循环只负责处理业务逻辑并通过 New-Event 发出通知,不关心谁来处理、怎么处理。而 Register-EngineEvent 注册的 Action 脚本块相当于一个独立的消费者,它通过 $Event 自动变量访问事件数据($Event.SourceEventArgs 对应 New-Event-EventArguments 参数)。注意最后必须调用 Unregister-Event 来清理订阅,否则事件监听器会一直驻留在 PowerShell 会话中。

进阶:使用 Register-ObjectEvent 监控文件系统变动

Register-ObjectEventRegister-EngineEvent 更强大,它可以订阅任意 .NET 对象的事件。最典型的应用场景是使用 System.IO.FileSystemWatcher 来监控目录的文件变动——当文件被创建、修改、重命名或删除时,自动触发处理逻辑。这在日志监控、配置热更新、文件同步等场景中非常实用。

下面的示例创建一个临时目录,用 FileSystemWatcher 监控其中的文件变动,然后模拟一系列文件操作来验证事件是否被正确捕获。

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
# 步骤 1:创建临时监控目录
$watchPath = Join-Path $env:TEMP "PSWatchDemo_$(Get-Random)"
New-Item -Path $watchPath -ItemType Directory | Out-Null

Write-Output "监控目录: $watchPath"
Write-Output ""

# 步骤 2:创建 FileSystemWatcher 并注册事件
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $watchPath
$watcher.Filter = "*.*"
$watcher.EnableRaisingEvents = $true
$watcher.IncludeSubdirectories = $false

# 注册文件创建事件
$createdAction = {
$name = $Event.SourceEventArgs.Name
$changeType = $Event.SourceEventArgs.ChangeType
$time = Get-Date -Format "HH:mm:ss.fff"
$script:fileEventLog += "[$time] 文件创建: $name"
}

# 注册文件修改事件
$changedAction = {
$name = $Event.SourceEventArgs.Name
$changeType = $Event.SourceEventArgs.ChangeType
$time = Get-Date -Format "HH:mm:ss.fff"
$script:fileEventLog += "[$time] 文件修改: $name"
}

# 注册文件删除事件
$deletedAction = {
$name = $Event.SourceEventArgs.Name
$changeType = $Event.SourceEventArgs.ChangeType
$time = Get-Date -Format "HH:mm:ss.fff"
$script:fileEventLog += "[$time] 文件删除: $name"
}

# 注册文件重命名事件
$renamedAction = {
$oldName = $Event.SourceEventArgs.OldName
$newName = $Event.SourceEventArgs.Name
$time = Get-Date -Format "HH:mm:ss.fff"
$script:fileEventLog += "[$time] 文件重命名: $oldName -> $newName"
}

# 初始化事件日志
$script:fileEventLog = @()

Register-ObjectEvent -InputObject $watcher -EventName "Created" -Action $createdAction -SourceIdentifier "FileCreated" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Changed" -Action $changedAction -SourceIdentifier "FileChanged" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Deleted" -Action $deletedAction -SourceIdentifier "FileDeleted" | Out-Null
Register-ObjectEvent -InputObject $watcher -EventName "Renamed" -Action $renamedAction -SourceIdentifier "FileRenamed" | Out-Null

# 步骤 3:模拟文件操作
Write-Output "开始模拟文件操作..."
Write-Output ""

# 创建文件
"Hello World" | Out-File -FilePath (Join-Path $watchPath "test.txt") -Encoding utf8
Start-Sleep -Milliseconds 300

# 修改文件
"Updated content at $(Get-Date)" | Out-File -FilePath (Join-Path $watchPath "test.txt") -Encoding utf8 -Append
Start-Sleep -Milliseconds 300

# 重命名文件
Rename-Item -Path (Join-Path $watchPath "test.txt") -NewName "renamed.txt"
Start-Sleep -Milliseconds 300

# 删除文件
Remove-Item -Path (Join-Path $watchPath "renamed.txt")
Start-Sleep -Milliseconds 300

# 创建多个新文件
foreach ($i in 1..3) {
"data-$i" | Out-File -FilePath (Join-Path $watchPath "file$i.dat") -Encoding utf8
Start-Sleep -Milliseconds 200
}

# 等待事件队列处理完毕
Start-Sleep -Milliseconds 500

# 步骤 4:输出捕获到的事件日志
Write-Output "=== 文件系统事件日志 ==="
foreach ($entry in $script:fileEventLog) {
Write-Output " $entry"
}

# 步骤 5:清理
Unregister-Event -SourceIdentifier "FileCreated"
Unregister-Event -SourceIdentifier "FileChanged"
Unregister-Event -SourceIdentifier "FileDeleted"
Unregister-Event -SourceIdentifier "FileRenamed"
$watcher.EnableRaisingEvents = $false
$watcher.Dispose()
Remove-Item -Path $watchPath -Recurse -Force -ErrorAction SilentlyContinue

Write-Output ""
Write-Output "事件监控已停止,临时目录已清理"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
监控目录: /tmp/PSWatchDemo_18473

开始模拟文件操作...

=== 文件系统事件日志 ===
[10:45:12.341] 文件创建: test.txt
[10:45:12.648] 文件修改: test.txt
[10:45:12.955] 文件重命名: test.txt -> renamed.txt
[10:45:13.261] 文件删除: renamed.txt
[10:45:13.567] 文件创建: file1.dat
[10:45:13.774] 文件创建: file2.dat
[10:45:13.981] 文件创建: file3.dat

事件监控已停止,临时目录已清理

这段代码有几个值得注意的设计要点。第一,FileSystemWatcher 必须设置 EnableRaisingEvents = $true 才能触发事件,否则它只是一个静默的对象。第二,Action 脚本块中的 $Event.SourceEventArgs 对应 .NET 事件参数(FileSystemEventArgs),其中包含 Name(文件名)、ChangeType(变动类型)等属性。对于重命名事件,参数类型是 RenamedEventArgs,额外提供 OldName 属性。第三,Action 脚本块在 PowerShell 的事件队列线程中执行,不在主线程中,因此需要使用 $script: 作用域修饰符来让主线程能够读取 Action 中记录的数据。第四,文件操作之间加入了 Start-Sleep 延迟,因为事件处理是异步的,需要给事件队列足够的处理时间。

实战:构建事件驱动的配置热更新系统

在实际运维中,应用程序的配置文件更新后通常需要重启服务才能生效。通过 PowerShell 的事件机制,我们可以构建一个”配置热更新”系统:监控配置文件的变动,当检测到变更时自动验证新配置的合法性,验证通过后触发重载操作,全程无需人工干预。

下面的示例演示了如何将 FileSystemWatcherRegister-EngineEvent 结合,构建一个多层事件处理管道。

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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# 步骤 1:创建模拟配置文件和目录
$configDir = Join-Path $env:TEMP "PSConfigHotReload_$(Get-Random)"
New-Item -Path $configDir -ItemType Directory | Out-Null

$configFile = Join-Path $configDir "appsettings.json"
$initialConfig = @{
AppName = "OrderService"
Version = "1.0.0"
Debug = $false
MaxRetries = 3
Timeout = 30
LogPath = "/var/log/orderservice"
} | ConvertTo-Json

$initialConfig | Out-File -FilePath $configFile -Encoding utf8

Write-Output "配置文件: $configFile"
Write-Output ""

# 步骤 2:定义配置验证和重载逻辑
$script:configState = @{
CurrentConfig = $null
ReloadHistory = @()
IsHealthy = $true
}

function Test-ConfigValid {
param([string]$JsonString)

try {
$config = $JsonString | ConvertFrom-Json
$errors = @()

if (-not $config.AppName) {
$errors += "缺少 AppName"
}
if ($config.Timeout -and $config.Timeout -lt 5) {
$errors += "Timeout 不能小于 5 秒"
}
if ($config.MaxRetries -and $config.MaxRetries -gt 10) {
$errors += "MaxRetries 不能大于 10"
}

if ($errors.Count -gt 0) {
return @{ Valid = $false; Errors = $errors; Config = $config }
}

return @{ Valid = $true; Errors = @(); Config = $config }
}
catch {
return @{ Valid = $false; Errors = @("JSON 解析失败: $($_.Exception.Message)"); Config = $null }
}
}

# 步骤 3:注册配置变更事件处理管道
# 第一层:FileSystemWatcher 检测文件修改
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $configDir
$watcher.Filter = "appsettings.json"
$watcher.EnableRaisingEvents = $true

$fileChangedAction = {
$filePath = $Event.SourceEventArgs.FullPath
$time = Get-Date -Format "HH:mm:ss"

Start-Sleep -Milliseconds 200

try {
$content = Get-Content -Path $filePath -Raw -ErrorAction Stop

# 将文件内容作为事件参数传递给下一层
$payload = [PSCustomObject]@{
FilePath = $filePath
Content = $content
DetectTime = $time
}

New-Event -SourceIdentifier "ConfigFileChanged" -EventArguments $payload | Out-Null
}
catch {
$script:configState.ReloadHistory += "[$time] 读取配置文件失败: $($_.Exception.Message)"
}
}

# 第二层:验证配置并执行重载
$configChangedAction = {
$payload = $Event.SourceEventArgs
$time = Get-Date -Format "HH:mm:ss"

$validation = Test-ConfigValid -JsonString $payload.Content

if ($validation.Valid) {
$script:configState.CurrentConfig = $validation.Config
$script:configState.IsHealthy = $true
$script:configState.ReloadHistory += "[$time] 配置重载成功: $($validation.Config.AppName) v$($validation.Config.Version) (Timeout=$($validation.Config.Timeout)s)"
}
else {
$script:configState.IsHealthy = $false
$errorList = $validation.Errors -join ", "
$script:configState.ReloadHistory += "[$time] 配置验证失败: $errorList (保持旧配置)"
}
}

Register-ObjectEvent -InputObject $watcher -EventName "Changed" -Action $fileChangedAction -SourceIdentifier "WatchConfig" | Out-Null
Register-EngineEvent -SourceIdentifier "ConfigFileChanged" -Action $configChangedAction -SourceIdentifier "HandleConfigChange" | Out-Null

# 初始加载配置
$initContent = Get-Content -Path $configFile -Raw
$initValidation = Test-ConfigValid -JsonString $initContent
$script:configState.CurrentConfig = $initValidation.Config
Write-Output "初始配置加载完成: $($initValidation.Config.AppName) v$($initValidation.Config.Version)"
Write-Output ""

# 步骤 4:模拟多次配置变更
Write-Output "=== 开始模拟配置变更 ==="
Write-Output ""

# 变更 1:正常更新版本号和超时时间
Write-Output "--- 变更 1: 更新版本号和超时 ---"
$updated1 = @{
AppName = "OrderService"
Version = "1.1.0"
Debug = $false
MaxRetries = 3
Timeout = 45
LogPath = "/var/log/orderservice"
} | ConvertTo-Json

$updated1 | Out-File -FilePath $configFile -Encoding utf8
Start-Sleep -Milliseconds 800

# 变更 2:设置无效的超时值(应该被拒绝)
Write-Output "--- 变更 2: 设置无效超时 (Timeout=2) ---"
$invalidConfig = @{
AppName = "OrderService"
Version = "1.2.0"
Debug = $true
MaxRetries = 3
Timeout = 2
LogPath = "/var/log/orderservice"
} | ConvertTo-Json

$invalidConfig | Out-File -FilePath $configFile -Encoding utf8
Start-Sleep -Milliseconds 800

# 变更 3:修正为有效配置
Write-Output "--- 变更 3: 修正配置 ---"
$updated3 = @{
AppName = "OrderService"
Version = "1.2.0"
Debug = $true
MaxRetries = 5
Timeout = 60
LogPath = "/var/log/orderservice/v2"
} | ConvertTo-Json

$updated3 | Out-File -FilePath $configFile -Encoding utf8
Start-Sleep -Milliseconds 800

# 步骤 5:输出重载历史
Write-Output ""
Write-Output "=== 配置重载历史 ==="
foreach ($entry in $script:configState.ReloadHistory) {
Write-Output " $entry"
}

Write-Output ""
Write-Output "=== 当前配置状态 ==="
Write-Output " 应用: $($script:configState.CurrentConfig.AppName)"
Write-Output " 版本: $($script:configState.CurrentConfig.Version)"
Write-Output " 调试: $($script:configState.CurrentConfig.Debug)"
Write-Output " 超时: $($script:configState.CurrentConfig.Timeout)s"
Write-Output " 重试: $($script:configState.CurrentConfig.MaxRetries)"
Write-Output " 健康: $($script:configState.IsHealthy)"

# 步骤 6:清理
Unregister-Event -SourceIdentifier "WatchConfig"
Unregister-Event -SourceIdentifier "HandleConfigChange"
$watcher.EnableRaisingEvents = $false
$watcher.Dispose()
Remove-Item -Path $configDir -Recurse -Force -ErrorAction SilentlyContinue

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
配置文件: /tmp/PSConfigHotReload_32891

初始配置加载完成: OrderService v1.0.0

=== 开始模拟配置变更 ===

--- 变更 1: 更新版本号和超时 ---
--- 变更 2: 设置无效超时 (Timeout=2) ---
--- 变更 3: 修正配置 ---

=== 配置重载历史 ===
[10:58:23] 配置重载成功: OrderService v1.1.0 (Timeout=45s)
[10:58:24] 配置验证失败: Timeout 不能小于 5 秒 (保持旧配置)
[10:58:25] 配置重载成功: OrderService v1.2.0 (Timeout=60s)

=== 当前配置状态 ===
应用: OrderService
版本: 1.2.0
调试: True
超时: 60s
重试: 5
健康: True

配置热更新系统已停止

这段代码展示了一个两层事件管道的设计。第一层是 FileSystemWatcherChanged 事件,它在文件被修改时触发,读取文件内容并通过 New-Event 将数据传递到第二层。第二层是 Register-EngineEvent 注册的 ConfigFileChanged 处理器,它负责验证配置的合法性并执行重载逻辑。这种分层设计的好处是每层只关注单一职责——文件监控层不关心配置验证,验证层不关心文件是怎么被修改的。变更 2 的故意设置了无效的 Timeout=2,验证函数正确地拒绝了这次变更,保持了上一次的有效配置不变。

注意事项

  1. Action 脚本块在后台线程中执行,不在主线程中。这意味着 Action 内部不能直接使用主线程的变量(除非使用 $script:$global: 作用域修饰符),也不能直接将输出写入管道。如果需要将 Action 的结果传回主线程,推荐的做法是在 Action 中写入 $script: 作用域的变量或使用 New-Event 传递到下一级事件处理器。

  2. FileSystemWatcher 可能对同一文件变更触发多次 Changed 事件。许多编辑器(如 VS Code)在保存文件时采用”写入临时文件-重命名替换”的策略,这会导致 ChangedRenamed 事件同时触发。即使使用简单的 Out-File,某些文件系统也可能产生多次事件。建议在 Action 中加入防抖逻辑(例如记录上次处理时间,两次处理间隔小于阈值则跳过)来避免重复处理。

  3. 事件队列是进程级的,不会跨 PowerShell 会话传播Register-EngineEventRegister-ObjectEvent 注册的处理器只对当前 PowerShell 会话(runsapce)有效。如果你在一个 PowerShell 进程中注册事件,然后在另一个 PowerShell 窗口中触发 New-Event,后者不会产生任何效果。跨进程的事件通信需要借助其他机制(如命名管道、消息队列或文件系统信号)。

  4. 大量注册的事件处理器会影响 PowerShell 性能。每个 Register-*Event 调用都会在事件队列中创建一个订阅,当订阅数量过多或事件触发频率过高时,PowerShell 的后台事件处理线程可能成为瓶颈。如果需要监控大量对象或高频率事件,建议限制同时活跃的订阅数量,或使用 .NET 的 TaskCancellationToken 来实现更高效的事件处理。

  5. Unregister-Event 必须在正确的时机调用。如果在 Action 脚本块正在执行时调用 Unregister-Event,可能导致 Action 被中断。推荐的做法是在脚本或模块的清理阶段(如 finally 块或模块的 OnRemove 事件中)统一注销所有事件。可以使用 Get-EventSubscriber 列出当前所有活跃的事件订阅,然后批量注销。

  6. $Event 自动变量只在 Action 脚本块内有效$Event 是 PowerShell 在调用 Action 时自动注入的,包含 SourceEventArgs(事件参数)、Sender(事件发送者)、TimeGenerated(事件生成时间)等属性。试图在 Action 外部访问 $Event 会得到 $null。同样,$EventSubscriber 自动变量提供了当前订阅的信息,可以用来在 Action 中动态注销自身。