PowerShell 技能连载 - 数据转换工具集

适用于 PowerShell 7.0 及以上版本

在运维自动化的日常工作中,数据格式转换几乎无处不在。你可能需要把 CSV 报表转成 JSON 传给 API,把 XML 配置文件解析成对象做比较,或者把 API 返回的数据加工成 CSV 交给业务方。这些看似零碎的操作,实际上构成了数据处理的基础链条。

PowerShell 作为一门面向对象的脚本语言,天然擅长处理结构化数据。它的管道机制让格式转换变得流畅——对象在管道中传递,随时可以在不同格式之间切换。配合 ConvertTo-JsonConvertFrom-JsonConvertTo-XmlConvertFrom-Csv 等内置 cmdlet,一条命令就能完成其他语言需要几十行代码才能实现的转换。

本文将围绕三个核心场景展开:基础格式互转、数据清洗与标准化,以及完整的 ETL 管道实战。掌握这些技巧后,你可以用 PowerShell 构建轻量级的数据处理管道,替代许多需要专门 ETL 工具才能完成的任务。

基础格式转换

PowerShell 内置了多种格式转换 cmdlet,可以在 CSV、JSON、XML 之间自由切换。下面的工具函数封装了常见的转换场景,支持链式调用。

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
function Convert-DataFormat {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
$InputObject,

[Parameter(Mandatory)]
[ValidateSet('Json', 'Csv', 'Xml', 'Yaml', 'HashTable')]
[string]$ToFormat,

[int]$Depth = 10
)

process {
switch ($ToFormat) {
'Json' {
$InputObject | ConvertTo-Json -Depth $Depth -EnumsAsStrings
}
'Csv' {
if ($InputObject -is [string]) {
$InputObject | ConvertFrom-Csv | ConvertTo-Csv -NoTypeInformation
}
else {
$InputObject | ConvertTo-Csv -NoTypeInformation
}
}
'Xml' {
$InputObject | ConvertTo-Xml -NoTypeInformation -As Stream
}
'HashTable' {
if ($InputObject -is [string]) {
$json = $InputObject
}
else {
$json = $InputObject | ConvertTo-Json -Depth $Depth -Compress
}
[System.Text.Json.JsonSerializer]::Deserialize(
$json,
[System.Collections.Generic.Dictionary[string, object]],
(New-Object System.Text.Json.JsonSerializerOptions -Property @{
PropertyNameCaseInsensitive = $true
})
)
}
'Yaml' {
# YAML 没有内置 cmdlet,通过 JSON 中转后手动格式化
$json = if ($InputObject -is [string]) { $InputObject } else {
$InputObject | ConvertTo-Json -Depth $Depth
}
$obj = $json | ConvertFrom-Json
ConvertTo-YamlString -InputObject $obj -Indent 0
}
}
}
}

function ConvertTo-YamlString {
param($InputObject, [int]$Indent = 0)
$space = ' ' * $Indent
$sb = [System.Text.StringBuilder]::new()

if ($InputObject -is [System.Collections.IEnumerable] -and
$InputObject -isnot [string]) {
foreach ($item in $InputObject) {
$null = $sb.Append("${space}- ")
if ($item -is [System.Collections.IDictionary] -or
$item -is [PSCustomObject]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $item ($Indent + 1)))
}
else {
$null = $sb.AppendLine("'$item'")
}
}
}
elseif ($InputObject -is [System.Collections.IDictionary] -or
$InputObject -is [PSCustomObject]) {
$props = if ($InputObject -is [System.Collections.IDictionary]) {
$InputObject.GetEnumerator()
}
else {
$InputObject.PSObject.Properties
}
foreach ($prop in $props) {
$name = $prop.Key ?? $prop.Name
$val = $prop.Value ?? $prop
$null = $sb.Append("${space}${name}: ")
if ($val -is [System.Collections.IEnumerable] -and
$val -isnot [string]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $val ($Indent + 1)))
}
elseif ($val -is [System.Collections.IDictionary] -or
$val -is [PSCustomObject]) {
$null = $sb.AppendLine()
$null = $sb.Append((ConvertTo-YamlString $val ($Indent + 1)))
}
else {
$null = $sb.AppendLine("'$val'")
}
}
}
else {
$null = $sb.AppendLine("${space}'$InputObject'")
}

$sb.ToString()
}

# 示例:CSV 转 JSON
$csvData = @'
Name,Department,Salary,StartDate
张三,工程部,25000,2024-03-15
李四,市场部,18000,2023-08-20
王五,工程部,30000,2022-11-01
赵六,人事部,22000,2025-01-10
'@

Write-Host "=== CSV 转 JSON ===" -ForegroundColor Cyan
$jsonOutput = $csvData | ConvertFrom-Csv | Convert-DataFormat -ToFormat Json
$jsonOutput

Write-Host "`n=== JSON 转 HashTable ===" -ForegroundColor Cyan
$jsonOutput | Convert-DataFormat -ToFormat HashTable |
ForEach-Object { $_.GetEnumerator() } |
Format-Table Key, Value -AutoSize

Write-Host "`n=== CSV 转 XML ===" -ForegroundColor Cyan
$xmlOutput = $csvData | ConvertFrom-Csv | Convert-DataFormat -ToFormat Xml
$xmlOutput | Select-Object -First 10

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
=== CSVJSON ===
[
{
"Name": "张三",
"Department": "工程部",
"Salary": "25000",
"StartDate": "2024-03-15"
},
{
"Name": "李四",
"Department": "市场部",
"Salary": "18000",
"StartDate": "2023-08-20"
},
{
"Name": "王五",
"Department": "工程部",
"Salary": "30000",
"StartDate": "2022-11-01"
},
{
"Name": "赵六",
"Department": "人事部",
"Salary": "22000",
"StartDate": "2025-01-10"
}
]

=== JSONHashTable ===
Key Value
--- -----
Name 张三
Department 工程部
Salary 25000
StartDate 2024-03-15

=== CSVXML ===
<?xml version="1.0" encoding="utf-8"?>
<Objects>
<Object Type="System.Management.Automation.PSCustomObject">
<Property Name="Name" Type="System.String">张三</Property>
<Property Name="Department" Type="System.String">工程部</Property>
<Property Name="Salary" Type="System.String">25000</Property>
<Property Name="StartDate" Type="System.String">2024-03-15</Property>
</Object>
...
</Objects>

数据清洗与标准化

原始数据往往不规范——字段名不统一、存在重复记录、类型混杂、空值缺失。在转换之前先做清洗,是保证数据质量的关键步骤。

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
function Invoke-DataCleanse {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[PSCustomObject]$InputObject,

# 字段映射表:旧名 → 新名
[Parameter(Mandatory)]
[hashtable]$FieldMap,

# 需要转换为 DateTime 的字段
[string[]]$DateFields,

# 需要转换为数值的字段
[string[]]$NumericFields,

# 默认空值填充
$DefaultNullValue = 'N/A'
)

begin {
$seen = [System.Collections.Generic.HashSet[string]]::new()
$duplicateCount = 0
$totalProcessed = 0
}

process {
$totalProcessed++

# 构建去重键(使用所有字段值的拼接)
$keyParts = $InputObject.PSObject.Properties.Value |
Where-Object { $_ } |
ForEach-Object { $_.ToString() }
$dedupKey = $keyParts -join '|'

if (-not $seen.Add($dedupKey)) {
$duplicateCount++
return
}

$result = [ordered]@{}

foreach ($prop in $InputObject.PSObject.Properties) {
$fieldName = $prop.Name
$value = $prop.Value

# 字段名映射
if ($FieldMap.ContainsKey($fieldName)) {
$fieldName = $FieldMap[$fieldName]
}

# 空值处理
if ([string]::IsNullOrWhiteSpace($value)) {
$value = $DefaultNullValue
}
else {
$value = $value.Trim()

# 日期字段转换
if ($DateFields -and $fieldName -in $DateFields) {
if ($value -ne $DefaultNullValue) {
if ([datetime]::TryParse($value, [ref]$parsed)) {
$value = $parsed
}
}
}

# 数值字段转换
if ($NumericFields -and $fieldName -in $NumericFields) {
if ($value -ne $DefaultNullValue) {
$cleaned = $value -replace '[,,]', ''
if ([double]::TryParse($cleaned, [ref]$num)) {
$value = $num
}
}
}
}

$result[$fieldName] = $value
}

[PSCustomObject]$result
}

end {
Write-Verbose "处理完成: 总计 $totalProcessed 条, 去除重复 $duplicateCount 条, 输出 $($totalProcessed - $duplicateCount) 条"
}
}

# 模拟原始脏数据
$rawData = @'
emp_name,dept,salary,start_date,email
张三,工程部,25000,2024-03-15,zhangsan@corp.com
李四,市场部,18000,2023-08-20,lisi@corp.com
张三,工程部,25000,2024-03-15,zhangsan@corp.com
王五,,30000,,
赵六,人事部,22,000,2025-01-10,zhaoliu@corp.com
孙七,工程部,28000,invalid-date,sunqi@corp.com
'@

$fieldMapping = @{
'emp_name' = 'EmployeeName'
'dept' = 'Department'
'salary' = 'AnnualSalary'
'start_date' = 'StartDate'
'email' = 'ContactEmail'
}

$cleaned = $rawData | ConvertFrom-Csv |
Invoke-DataCleanse `
-FieldMap $fieldMapping `
-DateFields 'StartDate' `
-NumericFields 'AnnualSalary' `
-DefaultNullValue '未知' `
-Verbose

Write-Host "=== 清洗结果 ===" -ForegroundColor Cyan
$cleaned | Format-Table -AutoSize

Write-Host "`n=== 数据类型验证 ===" -ForegroundColor Cyan
$cleaned | Get-Member -MemberType NoteProperty |
Select-Object Name, @{
N = 'SampleType'
E = {
$val = $cleaned[0].($_.Name)
if ($null -ne $val) { $val.GetType().Name } else { 'null' }
}
} | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
VERBOSE: 处理完成: 总计 6 条, 去除重复 1 条, 输出 5 条
=== 清洗结果 ===

EmployeeName Department AnnualSalary StartDate ContactEmail
------------ ---------- ------------ --------- ------------
张三 工程部 25000 2024/3/15 0:00:00 zhangsan@corp.com
李四 市场部 18000 2023/8/20 0:00:00 lisi@corp.com
王五 未知 30000 未知 未知
赵六 人事部 22000 2025/1/10 0:00:00 zhaoliu@corp.com
孙七 工程部 28000 未知 sunqi@corp.com

=== 数据类型验证 ===

Name SampleType
---- ----------
EmployeeName String
Department String
AnnualSalary Double
StartDate DateTime
ContactEmail String

ETL 管道实战

下面模拟一个真实场景:从多个数据源(CSV 文件、JSON API 响应、XML 配置)提取数据,统一转换后合并,最终输出为标准报表格式。这种轻量级 ETL 管道在运维报表、配置审计等场景中非常实用。

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
function Invoke-DataEtlPipeline {
[CmdletBinding()]
param(
[string]$OutputPath = './etl-output',
[switch]$IncludeSummary
)

# ---- Extract 阶段:从多源提取数据 ----

Write-Host "[Extract] 从 CSV 提取服务器资产数据..." -ForegroundColor Yellow
$serverCsv = @'
Hostname,IP,OS,CPU_CORES,RAM_GB,STATUS
WEB-01,192.168.1.10,Ubuntu 22.04,4,16,Running
WEB-02,192.168.1.11,Ubuntu 22.04,4,16,Running
DB-01,192.168.1.20,CentOS 7,8,64,Running
DB-02,192.168.1.21,CentOS 7,8,64,Stopped
CACHE-01,192.168.1.30,Ubuntu 22.04,2,8,Running
'@

$servers = $serverCsv | ConvertFrom-Csv
Write-Host " 提取 $($servers.Count) 条服务器记录"

Write-Host "[Extract] 从 JSON 提取监控指标数据..." -ForegroundColor Yellow
$metricsJson = @'
[
{"host": "WEB-01", "cpu_pct": 45.2, "mem_pct": 62.1, "disk_pct": 33.7, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "WEB-02", "cpu_pct": 12.8, "mem_pct": 45.3, "disk_pct": 28.9, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "DB-01", "cpu_pct": 78.5, "mem_pct": 85.2, "disk_pct": 71.4, "timestamp": "2026-02-23T08:00:00Z"},
{"host": "CACHE-01", "cpu_pct": 23.1, "mem_pct": 91.5, "disk_pct": 15.2, "timestamp": "2026-02-23T08:00:00Z"}
]
'@

$metrics = $metricsJson | ConvertFrom-Json
Write-Host " 提取 $($metrics.Count) 条监控指标"

Write-Host "[Extract] 从 XML 提取告警配置..." -ForegroundColor Yellow
$alertXml = @'
<alerts>
<rule name="cpu_high" threshold="70" severity="critical" target_pattern="DB-*"/>
<rule name="mem_warning" threshold="80" severity="warning" target_pattern="*"/>
<rule name="disk_high" threshold="60" severity="warning" target_pattern="DB-*"/>
</alerts>
'@

[xml]$xmlDoc = $alertXml
$alertRules = $xmlDoc.alerts.rule | ForEach-Object {
[PSCustomObject]@{
RuleName = $_.name
Threshold = [int]$_.threshold
Severity = $_.severity
TargetPattern = $_.target_pattern
}
}
Write-Host " 提取 $($alertRules.Count) 条告警规则"

# ---- Transform 阶段:关联、转换、计算 ----

Write-Host "`n[Transform] 关联服务器资产与监控指标..." -ForegroundColor Green

$enriched = foreach ($srv in $servers) {
$metric = $metrics | Where-Object { $_.host -eq $srv.Hostname }
$triggeredAlerts = @()

foreach ($rule in $alertRules) {
$pattern = $rule.TargetPattern -replace '\*', '.*'
if ($srv.Hostname -match "^$pattern$") {
$violated = $false
$fieldMap = @{
'cpu_high' = 'cpu_pct'
'mem_warning' = 'mem_pct'
'disk_high' = 'disk_pct'
}
$fieldName = $fieldMap[$rule.RuleName]
if ($fieldName -and $metric -and
$metric.$fieldName -gt $rule.Threshold) {
$violated = $true
}
if ($violated) {
$triggeredAlerts += "[{0}] {1} ({2}% > {3}%)" -f
$rule.Severity, $rule.RuleName,
$metric.$fieldName, $rule.Threshold
}
}
}

[PSCustomObject][ordered]@{
Hostname = $srv.Hostname
IPAddress = $srv.IP
OS = $srv.OS
CPU_Cores = [int]$srv.CPU_CORES
RAM_GB = [int]$srv.RAM_GB
Status = $srv.STATUS
CPU_Usage = if ($metric) { $metric.cpu_pct } else { $null }
MEM_Usage = if ($metric) { $metric.mem_pct } else { $null }
DISK_Usage = if ($metric) { $metric.disk_pct } else { $null }
AlertCount = $triggeredAlerts.Count
Alerts = if ($triggeredAlerts) {
$triggeredAlerts -join '; '
} else { '无' }
}
}

# ---- Load 阶段:输出到多种目标格式 ----

$null = New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction SilentlyContinue

Write-Host "`n[Load] 导出为 CSV 报表..." -ForegroundColor Magenta
$csvFile = Join-Path $OutputPath 'server-report.csv'
$enriched | ConvertTo-Csv -NoTypeInformation | Out-File $csvFile -Encoding utf8
Write-Host " 已写入: $csvFile"

Write-Host "[Load] 导出为 JSON..." -ForegroundColor Magenta
$jsonFile = Join-Path $OutputPath 'server-report.json'
$enriched | ConvertTo-Json -Depth 5 | Out-File $jsonFile -Encoding utf8
Write-Host " 已写入: $jsonFile"

Write-Host "[Load] 导出为 HTML 报表..." -ForegroundColor Magenta
$htmlFile = Join-Path $OutputPath 'server-report.html'
$htmlParams = @{
Title = '服务器状态报表'
Body = '<h1>服务器状态报表</h1>' +
'<p>生成时间: {0}</p>' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Head = '<style>table { border-collapse: collapse; } ' +
'td, th { border: 1px solid #ccc; padding: 6px; } ' +
'.critical { color: red; font-weight: bold; }</style>'
}
$enriched | ConvertTo-Html @htmlParams | Out-File $htmlFile -Encoding utf8
Write-Host " 已写入: $htmlFile"

if ($IncludeSummary) {
Write-Host "`n=== ETL 汇总 ===" -ForegroundColor Cyan
Write-Host (" 服务器总数: {0}" -f $enriched.Count)
Write-Host (" 运行中: {0}" -f ($enriched | Where-Object Status -eq 'Running').Count)
Write-Host (" 已停机: {0}" -f ($enriched | Where-Object Status -eq 'Stopped').Count)
Write-Host (" 触发告警的服务器: {0}" -f ($enriched | Where-Object AlertCount -gt 0).Count)
Write-Host (" 平均 CPU 使用率: {0:N1}%" -f (
($enriched | Where-Object CPU_Usage | Measure-Object -Property CPU_Usage -Average).Average
))
Write-Host (" 平均内存使用率: {0:N1}%" -f (
($enriched | Where-Object MEM_Usage | Measure-Object -Property MEM_Usage -Average).Average
))
}

return $enriched
}

# 执行完整 ETL 管道
$result = Invoke-DataEtlPipeline -IncludeSummary

Write-Host "`n=== 最终报表 ===" -ForegroundColor Cyan
$result | Format-Table Hostname, IPAddress, OS, Status,
@{N='CPU%';E={'{0:N1}' -f $_.CPU_Usage}},
@{N='MEM%';E={'{0:N1}' -f $_.MEM_Usage}},
@{N='DISK%';E={'{0:N1}' -f $_.DISK_Usage}},
AlertCount -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
31
32
33
[Extract] 从 CSV 提取服务器资产数据...
提取 5 条服务器记录
[Extract] 从 JSON 提取监控指标数据...
提取 4 条监控指标
[Extract] 从 XML 提取告警配置...
提取 3 条告警规则

[Transform] 关联服务器资产与监控指标...

[Load] 导出为 CSV 报表...
已写入: ./etl-output/server-report.csv
[Load] 导出为 JSON...
已写入: ./etl-output/server-report.json
[Load] 导出为 HTML 报表...
已写入: ./etl-output/server-report.html

=== ETL 汇总 ===
服务器总数: 5
运行中: 4
已停机: 1
触发告警的服务器: 2
平均 CPU 使用率: 39.9%
平均内存使用率: 71.0%

=== 最终报表 ===

Hostname IPAddress OS Status CPU% MEM% DISK% AlertCount
-------- --------- -- ------ ---- ---- ----- ----------
WEB-01 192.168.1.10 Ubuntu 22.04 Running 45.2 62.1 33.7 0
WEB-02 192.168.1.11 Ubuntu 22.04 Running 12.8 45.3 28.9 0
DB-01 192.168.1.20 CentOS 7 Running 78.5 85.2 71.4 3
DB-02 192.168.1.21 CentOS 7 Running 0
CACHE-01 192.168.1.30 Ubuntu 22.04 Running 23.1 91.5 15.2 1

注意事项

  1. ConvertTo-Json 的 Depth 参数:默认 Depth 只有 2,嵌套对象会被截断为 System.Object。处理复杂对象时务必显式指定足够的深度,建议设为 10 以上。

  2. CSV 的类型丢失问题:CSV 格式本质上是纯文本,所有值都会变成字符串。从 CSV 读取后需要手动对日期、数值字段做类型转换,否则后续计算会出错。

  3. 大文件的内存占用ConvertFrom-JsonConvertFrom-Csv 会一次性将全部数据加载到内存。处理数百 MB 以上的文件时,考虑使用流式处理或分批读取,避免内存溢出。

  4. XML 命名空间处理:真实的 XML 文档通常带有命名空间(如 xmlns),直接用 Select-Xml 可能匹配不到节点。需要使用 NamespaceManager 注册前缀,或者用 [xml] 类型加速器的 dot 访问绕过命名空间。

  5. 编码一致性:导出文件时显式指定 -Encoding utf8(PowerShell 7 默认即为 UTF-8,但在 Windows PowerShell 5.1 中默认是 ASCII)。跨平台场景中统一用 UTF-8 可以避免中文乱码。

  6. 管道中的去重性能HashSet 去重在数据量小时效率很高,但当记录达到数十万条时,拼接去重键的字符串操作会成为瓶颈。大数据量场景建议改用基于主键的字典查找。

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 技能连载 - JSON Schema 验证

适用于 PowerShell 7.0 及以上版本

JSON 已经成为配置文件和 API 数据交换的事实标准,但 JSON 本身不包含类型和结构约束——一个字段应该是字符串还是数字、一个数组至少有几项、哪些字段是必填的,这些都需要额外的验证。JSON Schema 是一套标准的 JSON 结构描述语言,可以精确验证 JSON 数据的格式和内容。结合 PowerShell 的 JSON 处理能力,可以构建可靠的配置验证系统。

本文将讲解如何在 PowerShell 中实现 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
# 不使用外部库的基础验证
function Test-JsonStructure {
param(
[Parameter(Mandatory)]
$Data,

[Parameter(Mandatory)]
[hashtable]$Schema
)

$errors = @()

# 检查必填字段
if ($Schema.required) {
foreach ($field in $Schema.required) {
if (-not ($Data.PSObject.Properties.Name -contains $field)) {
$errors += "缺少必填字段:$field"
}
}
}

# 检查字段类型
if ($Schema.properties) {
foreach ($prop in $Schema.properties.GetEnumerator()) {
$name = $prop.Key
$rules = $prop.Value

if (-not ($Data.PSObject.Properties.Name -contains $name)) { continue }

$value = $Data.$name

# 类型检查
if ($rules.type) {
$typeOk = switch ($rules.type) {
"string" { $value -is [string] }
"integer" { $value -is [int] -or $value -is [long] }
"number" { $value -is [double] -or $value -is [int] }
"boolean" { $value -is [bool] }
"array" { $value -is [array] }
"object" { $value -is [System.Management.Automation.PSCustomObject] }
}
if (-not $typeOk) {
$errors += "$name 类型错误:期望 $($rules.type),实际 $($value.GetType().Name)"
}
}

# 字符串长度
if ($rules.minLength -and $value.Length -lt $rules.minLength) {
$errors += "$name 长度不足:最少 $($rules.minLength) 字符"
}

# 数字范围
if ($rules.minimum -and $value -lt $rules.minimum) {
$errors += "$name$value 小于最小值 $($rules.minimum)"
}
if ($rules.maximum -and $value -gt $rules.maximum) {
$errors += "$name$value 大于最大值 $($rules.maximum)"
}

# 枚举值
if ($rules.enum -and $value -notin $rules.enum) {
$errors += "$name 值 '$value' 不在允许列表中"
}

# 正则匹配
if ($rules.pattern -and $value -notmatch $rules.pattern) {
$errors += "$name 值 '$value' 不匹配模式"
}
}
}

if ($errors) {
Write-Host "验证失败($($errors.Count) 个问题):" -ForegroundColor Red
$errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
return $false
}

Write-Host "验证通过" -ForegroundColor Green
return $true
}

# 定义 Schema 并验证
$json = '{"name":"MyApp","version":"2.5.0","port":8080,"debug":false}'
$data = $json | ConvertFrom-Json

$schema = @{
required = @("name", "version", "port")
properties = @{
name = @{ type = "string"; minLength = 1 }
version = @{ type = "string"; pattern = "^\d+\.\d+\.\d+$" }
port = @{ type = "integer"; minimum = 1; maximum = 65535 }
debug = @{ type = "boolean" }
}
}

Test-JsonStructure -Data $data -Schema $schema

执行结果示例:

1
验证通过

嵌套结构验证

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
# 支持嵌套 JSON 的验证
function Test-JsonDeep {
param(
[Parameter(Mandatory)]
[object]$Data,

[Parameter(Mandatory)]
[hashtable]$Schema,

[string]$Path = ""
)

$errors = @()

if ($Schema.required) {
foreach ($field in $Schema.required) {
if (-not ($Data.PSObject.Properties.Name -contains $field)) {
$errors += "${Path}.$field : 缺少必填字段"
}
}
}

if ($Schema.properties) {
foreach ($prop in $Schema.properties.GetEnumerator()) {
$name = $prop.Key
$rules = $prop.Value
$fullPath = if ($Path) { "$Path.$name" } else { $name }

if (-not ($Data.PSObject.Properties.Name -contains $name)) { continue }
$value = $Data.$name

# 嵌套对象递归验证
if ($rules.type -eq "object" -and $rules.properties -and $value -is [PSCustomObject]) {
$nestedErrors = Test-JsonDeep -Data $value -Schema $rules -Path $fullPath
$errors += $nestedErrors
continue
}

# 数组元素验证
if ($rules.type -eq "array" -and $value -is [array]) {
if ($rules.minItems -and $value.Count -lt $rules.minItems) {
$errors += "$fullPath : 数组元素不足 $($rules.minItems) 个"
}
if ($rules.items -and $rules.items.type -eq "object") {
foreach ($item in $value) {
if ($item -is [PSCustomObject]) {
$nestedErrors = Test-JsonDeep -Data $item -Schema $rules.items -Path "$fullPath[]"
$errors += $nestedErrors
}
}
}
}

# 基本类型验证
if ($rules.type -eq "string" -and $rules.pattern -and $value -is [string]) {
if ($value -notmatch $rules.pattern) {
$errors += "$fullPath : 值 '$value' 不匹配模式 $($rules.pattern)"
}
}

if ($rules.type -eq "integer" -and $rules.minimum) {
if ([int]$value -lt $rules.minimum) {
$errors += "$fullPath : 值 $value 小于最小值 $($rules.minimum)"
}
}
}
}

return $errors
}

# 验证嵌套配置
$configJson = @"
{
"app": {
"name": "MyApp",
"version": "2.5.0"
},
"database": {
"host": "prod-db01",
"port": 5432,
"name": "myapp"
},
"servers": [
{ "name": "SRV01", "role": "web" },
{ "name": "SRV02", "role": "db" }
]
}
"@

$config = $configJson | ConvertFrom-Json

$configSchema = @{
required = @("app", "database", "servers")
properties = @{
app = @{
type = "object"
required = @("name", "version")
properties = @{
name = @{ type = "string" }
version = @{ type = "string"; pattern = "^\d+\.\d+\.\d+$" }
}
}
database = @{
type = "object"
required = @("host", "port")
properties = @{
host = @{ type = "string" }
port = @{ type = "integer"; minimum = 1; maximum = 65535 }
}
}
servers = @{
type = "array"
minItems = 1
items = @{
type = "object"
required = @("name", "role")
properties = @{
name = @{ type = "string" }
role = @{ type = "string" }
}
}
}
}
}

$errors = Test-JsonDeep -Data $config -Schema $configSchema
if ($errors) {
Write-Host "验证错误:" -ForegroundColor Red
$errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
} else {
Write-Host "配置验证通过" -ForegroundColor Green
}

执行结果示例:

1
配置验证通过

配置文件验证工具

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
# 配置文件自动验证
function Protect-ConfigFile {
param(
[Parameter(Mandatory)]
[string]$ConfigPath,

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

[switch]$FailFast
)

$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$schemaDef = Get-Content $SchemaPath -Raw | ConvertFrom-Json

# 将 PSCustomObject Schema 转为哈希表
function ConvertTo-SchemaHashtable {
param($obj)
$hash = @{}
$obj.PSObject.Properties | ForEach-Object {
if ($_.Value -is [System.Management.Automation.PSCustomObject]) {
$hash[$_.Name] = ConvertTo-SchemaHashtable $_.Value
} elseif ($_.Value -is [array]) {
$hash[$_.Name] = foreach ($item in $_.Value) {
if ($item -is [System.Management.Automation.PSCustomObject]) {
ConvertTo-SchemaHashtable $item
} else { $item }
}
} else {
$hash[$_.Name] = $_.Value
}
}
return $hash
}

$schemaHash = ConvertTo-SchemaHashtable $schemaDef
$errors = Test-JsonDeep -Data $config -Schema $schemaHash

if ($errors) {
Write-Host "配置文件验证失败:$ConfigPath" -ForegroundColor Red
$errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
if ($FailFast) { throw "配置验证失败" }
return $false
}

Write-Host "配置文件验证通过:$ConfigPath" -ForegroundColor Green
return $true
}

# 批量验证配置文件
$configDir = "C:\MyApp\Config"
$schemaFile = "C:\MyApp\Schemas\app-schema.json"

Get-ChildItem $configDir -Filter "*.json" | ForEach-Object {
Protect-ConfigFile -ConfigPath $_.FullName -SchemaPath $schemaFile
}

执行结果示例:

1
2
3
配置文件验证通过:C:\MyApp\Config\app-dev.json
配置文件验证通过:C:\MyApp\Config\app-staging.json
配置文件验证通过:C:\MyApp\Config\app-prod.json

注意事项

  1. NuGet 包:生产环境可以使用 Newtonsoft.Json.Schema 包获得完整的 JSON Schema Draft-07 支持
  2. Schema 版本:JSON Schema 有多个版本(Draft-04、Draft-06、Draft-07、2020-12),注意兼容性
  3. 错误信息:手动验证的错误信息尽量包含字段路径和期望值,方便定位问题
  4. CI/CD 集成:配置文件验证应在部署流水线的早期阶段执行,阻止无效配置上线
  5. Schema 即文档:维护良好的 JSON Schema 可以同时作为配置文档使用
  6. 性能:大型 JSON 的深度验证可能较慢,关键路径上考虑只验证必要字段

PowerShell 技能连载 - 数据迁移与转换技巧

适用于 PowerShell 5.1 及以上版本

在 IT 运维和开发工作中,数据迁移和格式转换是高频需求。系统升级时需要将用户数据从旧格式迁移到新格式,报表汇总时需要合并多个 CSV 文件并转换字段,跨系统对接时需要在 JSON、CSV、XML、YAML 之间来回转换。这些任务看似简单,但涉及编码问题、类型映射、空值处理、增量同步等细节时往往让人头疼。

PowerShell 内置了 ConvertTo-CsvConvertFrom-JsonConvertTo-Xml 等丰富的转换 cmdlet,配合管道操作可以快速构建 ETL(Extract-Transform-Load)流程。本文将从实际场景出发,讲解数据迁移和转换中的常用技巧。

CSV 数据读取与清洗

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
# 场景:从遗留系统导出的 CSV 数据质量差,需要清洗后再导入新系统

$rawCsv = @"
EmployeeID,Name,Department,Salary,HireDate,Status
E001,张三,技术部,15000.50,2020-03-15,Active
E002,李四,市场部,,2021-07-20,Active
E003,王五,技术部,18000,2022-01-10,Inactive
E004,,财务部,12000,2023/06/01,Active
E005,赵六,技术部,20000.99,invalid,Active
E006,钱七,,16500,2020-11-30,Active
"@

function Import-AndCleanCsv {
param(
[Parameter(Mandatory)]
[string[]]$CsvLines,

[string]$DefaultDepartment = "未分配",

[decimal]$DefaultSalary = 0
)

# 导入 CSV
$data = $CsvLines | ConvertFrom-Csv

$cleaned = foreach ($row in $data) {
# 清洗姓名:去除空值
$name = if ([string]::IsNullOrWhiteSpace($row.Name)) {
"未知员工($($row.EmployeeID))"
} else {
$row.Name.Trim()
}

# 清洗部门:空值使用默认值
$dept = if ([string]::IsNullOrWhiteSpace($row.Department)) {
$DefaultDepartment
} else {
$row.Department.Trim()
}

# 清洗薪资:非数字使用默认值
$salary = if ($row.Salary -match '^\d+\.?\d*$') {
[decimal]$row.Salary
} else {
$DefaultSalary
}

# 清洗日期:多种格式统一转换
$hireDate = $null
if ($row.HireDate -match '^\d{4}-\d{2}-\d{2}$') {
$hireDate = [datetime]::ParseExact($row.HireDate, "yyyy-MM-dd", $null)
} elseif ($row.HireDate -match '^\d{4}/\d{2}/\d{2}$') {
$hireDate = [datetime]::ParseExact($row.HireDate, "yyyy/MM/dd", $null)
}

# 输出清洗后的对象
[PSCustomObject]@{
EmployeeID = $row.EmployeeID
Name = $name
Department = $dept
Salary = $salary
HireDate = if ($hireDate) { $hireDate.ToString("yyyy-MM-dd") } else { "N/A" }
Status = $row.Status
Cleaned = $true
}
}

return $cleaned
}

$cleanData = Import-AndCleanCsv -CsvLines ($rawCsv -split "`n")
$cleanData | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
EmployeeID Name          Department Salary   HireDate   Status  Cleaned
---------- ---- ---------- ------ -------- ------ -------
E001 张三 技术部 15000.50 2020-03-15 Active True
E002 李四 市场部 0.00 2021-07-20 Active True
E003 王五 技术部 18000.00 2022-01-10 Inactive True
E004 未知员工(E004) 财务部 12000.00 2023-06-01 Active True
E005 赵六 技术部 20000.99 N/A Active True
E006 钱七 未分配 16500.00 2020-11-30 Active True

CSV 转 JSON 与嵌套结构

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
# 场景:将扁平的 CSV 数据转换为嵌套的 JSON 结构供 API 消费

$flatData = @"
ID,Name,Email,DeptID,DeptName,RoleID,RoleName
U001,张三,zhangsan@contoso.com,D001,技术部,R001,开发工程师
U002,李四,lisi@contoso.com,D001,技术部,R002,测试工程师
U003,王五,wangwu@contoso.com,D002,市场部,R003,市场经理
U004,赵六,zhaoliu@contoso.com,D002,市场部,R003,市场经理
"@

function ConvertFrom-FlatToNestedJson {
param(
[Parameter(Mandatory)]
[string[]]$CsvLines
)

$data = $CsvLines | ConvertFrom-Csv

# 按部门分组,构建嵌套结构
$nested = $data | Group-Object -Property DeptID | ForEach-Object {
$deptGroup = $_

# 按角色分组
$roles = $deptGroup.Group | Group-Object -Property RoleID | ForEach-Object {
$roleGroup = $_
$members = $roleGroup.Group | ForEach-Object {
@{
id = $_.ID
name = $_.Name
email = $_.Email
}
}

@{
roleId = $roleGroup.Name
roleName = ($roleGroup.Group | Select-Object -First 1).RoleName
members = @($members)
}
}

@{
departmentId = $deptGroup.Name
departmentName = ($deptGroup.Group | Select-Object -First 1).DeptName
roles = @($roles)
}
}

$result = @{
exportDate = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
totalCount = $data.Count
departments = @($nested)
}

return $result | ConvertTo-Json -Depth 5
}

$jsonOutput = ConvertFrom-FlatToNestedJson -CsvLines ($flatData -split "`n")
Write-Host $jsonOutput

执行结果示例:

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
{
"exportDate": "2025-08-06T08:30:00",
"totalCount": 4,
"departments": [
{
"departmentId": "D001",
"departmentName": "技术部",
"roles": [
{
"roleId": "R001",
"roleName": "开发工程师",
"members": [
{
"id": "U001",
"name": "张三",
"email": "zhangsan@contoso.com"
}
]
},
{
"roleId": "R002",
"roleName": "测试工程师",
"members": [
{
"id": "U002",
"name": "李四",
"email": "lisi@contoso.com"
}
]
}
]
},
{
"departmentId": "D002",
"departmentName": "市场部",
"roles": [
{
"roleId": "R003",
"roleName": "市场经理",
"members": [
{ "id": "U003", "name": "王五", "email": "wangwu@contoso.com" },
{ "id": "U004", "name": "赵六", "email": "zhaoliu@contoso.com" }
]
}
]
}
]
}

JSON 转 CSV 与字段展开

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
# 场景:从 API 获取的嵌套 JSON 需要展开为扁平的 CSV 供报表使用

$apiJson = @"
{
"users": [
{
"id": "U001",
"profile": {
"name": "张三",
"age": 28,
"address": {
"city": "北京",
"district": "海淀区"
}
},
"skills": ["PowerShell", "Python", "Docker"],
"active": true
},
{
"id": "U002",
"profile": {
"name": "李四",
"age": 32,
"address": {
"city": "上海",
"district": "浦东新区"
}
},
"skills": ["C#", "SQL Server"],
"active": false
}
]
}
"@

function Expand-NestedJsonToCsv {
param(
[Parameter(Mandatory)]
[string]$JsonString
)

$data = $JsonString | ConvertFrom-Json

$flatRows = foreach ($user in $data.users) {
[PSCustomObject]@{
ID = $user.id
Name = $user.profile.name
Age = $user.profile.age
City = $user.profile.address.city
District = $user.profile.address.district
Skills = ($user.skills -join "; ")
SkillCount = $user.skills.Count
Active = $user.active
}
}

return $flatRows
}

$flatRows = Expand-NestedJsonToCsv -JsonString $apiJson
$flatRows | Export-Csv -Path "$env:TEMP\users_flat.csv" -NoTypeInformation -Encoding UTF8
Write-Host "已导出到 $env:TEMP\users_flat.csv"
Get-Content "$env:TEMP\users_flat.csv"

执行结果示例:

1
2
3
4
已导出到 C:\Users\admin\AppData\Local\Temp\users_flat.csv
"ID","Name","Age","City","District","Skills","SkillCount","Active"
"U001","张三","28","北京","海淀区","PowerShell; Python; Docker","3","True"
"U002","李四","32","上海","浦东新区","C#; SQL Server","2","False"

增量数据迁移与变更追踪

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
# 场景:定期从源系统迁移数据到目标系统,只迁移新增或变更的记录

function Start-IncrementalMigration {
param(
[Parameter(Mandatory)]
[string]$SourcePath,

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

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

[string]$CompareField = "UpdatedAt"
)

# 加载源数据
$source = Import-Csv -Path $SourcePath -Encoding UTF8
$target = if (Test-Path $TargetPath) {
Import-Csv -Path $TargetPath -Encoding UTF8
} else {
@()
}

# 构建目标数据的索引
$targetIndex = @{}
foreach ($row in $target) {
$targetIndex[$row.$KeyField] = $row
}

$stats = @{
Total = $source.Count
Inserted = 0
Updated = 0
Skipped = 0
}

$result = foreach ($srcRow in $source) {
$key = $srcRow.$KeyField

if ($targetIndex.ContainsKey($key)) {
# 记录已存在,检查是否需要更新
$tgtRow = $targetIndex[$key]
$srcVal = $srcRow.$CompareField
$tgtVal = $tgtRow.$CompareField

if ($srcVal -gt $tgtVal) {
# 源数据更新,覆盖目标
$srcRow | Add-Member -MemberType NoteProperty -Name "_action" -Value "UPDATE" -Force
$stats.Updated++
$srcRow
} else {
# 无变化,跳过
$stats.Skipped++
}
} else {
# 新记录,插入
$srcRow | Add-Member -MemberType NoteProperty -Name "_action" -Value "INSERT" -Force
$stats.Inserted++
$srcRow
}
}

# 合并数据:保留未变更的目标记录 + 更新/新增的记录
$unchanged = $target | Where-Object {
$key = $_.$KeyField
$srcKeys = $source | ForEach-Object { $_.$KeyField }
$key -in $srcKeys -and -not ($result | Where-Object { $_.$KeyField -eq $key })
}

$finalData = @($unchanged) + @($result | Where-Object { $_._action -eq "UPDATE" }) +
@($result | Where-Object { $_._action -eq "INSERT" })

# 移除内部字段后导出
$finalData | ForEach-Object { $_.PSObject.Properties.Remove("_action") }
$finalData | Export-Csv -Path $TargetPath -NoTypeInformation -Encoding UTF8

Write-Host "迁移完成:" -ForegroundColor Green
Write-Host " 总记录数:$($stats.Total)"
Write-Host " 新增:$($stats.Inserted)" -ForegroundColor Cyan
Write-Host " 更新:$($stats.Updated)" -ForegroundColor Yellow
Write-Host " 跳过(无变化):$($stats.Skipped)" -ForegroundColor Gray

return $stats
}

# 使用示例(模拟源数据)
$sourceData = @"
ID,Name,Department,UpdatedAt
E001,张三,技术部,2025-08-01
E002,李四,市场部,2025-08-05
E003,王五,财务部,2025-08-06
"@

$targetData = @"
ID,Name,Department,UpdatedAt
E001,张三,技术部,2025-07-15
E002,李四,市场部,2025-08-05
"@

$sourceData | Set-Content "$env:TEMP\source.csv" -Encoding UTF8
$targetData | Set-Content "$env:TEMP\target.csv" -Encoding UTF8

Start-IncrementalMigration -SourcePath "$env:TEMP\source.csv" `
-TargetPath "$env:TEMP\target.csv" `
-KeyField "ID" `
-CompareField "UpdatedAt"

执行结果示例:

1
2
3
4
5
迁移完成:
总记录数:3
新增:1
更新:1
跳过(无变化):1

多文件合并与格式批量转换

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
# 场景:批量合并多个 CSV 文件,统一格式后输出为 JSON

function Merge-MultipleCsv {
param(
[Parameter(Mandatory)]
[string]$InputFolder,

[string]$Filter = "*.csv",

[string]$OutputPath,

[string[]]$FieldMapping
)

$allData = @()
$files = Get-ChildItem -Path $InputFolder -Filter $Filter -File

Write-Host "找到 $($files.Count) 个文件待合并" -ForegroundColor Cyan

foreach ($file in $files) {
Write-Host " 处理:$($file.Name)" -ForegroundColor Gray

$csv = Import-Csv -Path $file.FullName -Encoding UTF8

foreach ($row in $csv) {
# 如果指定了字段映射,只提取需要的字段
if ($FieldMapping) {
$mappedRow = @{}
foreach ($field in $FieldMapping) {
if ($row.PSObject.Properties[$field]) {
$mappedRow[$field] = $row.$field
}
}
$mappedRow["_sourceFile"] = $file.Name
$allData += [PSCustomObject]$mappedRow
} else {
$row | Add-Member -MemberType NoteProperty -Name "_sourceFile" `
-Value $file.Name -Force
$allData += $row
}
}
}

Write-Host "合并完成,共 $($allData.Count) 条记录" -ForegroundColor Green

# 根据输出路径的扩展名决定格式
if ($OutputPath) {
$ext = [System.IO.Path]::GetExtension($OutputPath).ToLower()
switch ($ext) {
".json" {
$allData | ConvertTo-Json -Depth 3 | Set-Content $OutputPath -Encoding UTF8
}
".csv" {
$allData | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
".xml" {
$allData | ConvertTo-Xml -NoTypeInformation | Select-Object -ExpandProperty OuterXml |
Set-Content $OutputPath -Encoding UTF8
}
default {
$allData | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
}
Write-Host "已输出到:$OutputPath" -ForegroundColor Green
}

return $allData
}

# 批量格式转换工具
function Convert-DataFormat {
param(
[Parameter(Mandatory)]
[string]$InputPath,

[Parameter(Mandatory)]
[string]$OutputPath
)

$ext = [System.IO.Path]::GetExtension($InputPath).ToLower()

# 读取数据
$data = switch ($ext) {
".csv" { Import-Csv -Path $InputPath -Encoding UTF8 }
".json" { Get-Content $InputPath -Raw | ConvertFrom-Json }
default { throw "不支持的输入格式:$ext" }
}

# 如果 JSON 顶层是数组,ConvertFrom-Json 已经返回数组
# 如果是单个对象,包装为数组
if ($data -isnot [System.Array]) {
$data = @($data)
}

# 写入目标格式
$outExt = [System.IO.Path]::GetExtension($OutputPath).ToLower()
switch ($outExt) {
".json" {
$data | ConvertTo-Json -Depth 5 | Set-Content $OutputPath -Encoding UTF8
}
".csv" {
$data | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
default {
throw "不支持的输出格式:$outExt"
}
}

Write-Host "转换完成:$InputPath -> $OutputPath" -ForegroundColor Green
}

# 示例
Convert-DataFormat -InputPath "$env:TEMP\source.csv" -OutputPath "$env:TEMP\source.json"

执行结果示例:

1
2
3
4
5
6
找到 3 个文件待合并
处理:employees_dept_a.csv
处理:employees_dept_b.csv
处理:employees_dept_c.csv
合并完成,共 15 条记录
转换完成:C:\Users\admin\AppData\Local\Temp\source.csv -> C:\Users\admin\AppData\Local\Temp\source.json

注意事项

  1. 编码一致性:读写文件时始终显式指定 -Encoding UTF8,避免中文乱码,尤其在不同 Windows 版本间迁移时
  2. 大文件性能:超过 10 万行的 CSV 不要用 Import-Csv 一次性加载到内存,应逐行流式处理或使用 StreamReader
  3. 日期格式标准化:迁移前统一日期格式(推荐 ISO 8601),避免 2025/08/062025-08-06 混用导致比较失败
  4. 空值策略:明确区分空字符串、$null 和缺失值,迁移前定义好每种情况的处理规则(跳过、默认值、报错)
  5. 事务性保证:关键数据迁移应在导入前备份目标数据,失败时能回滚到迁移前的状态
  6. 字段映射验证:源和目标的字段名、类型、长度可能不同,迁移前应做字段映射表并逐条校验

PowerShell 技能连载 - JSON 与 YAML 配置管理

适用于 PowerShell 7.0 及以上版本

在 DevOps 和基础设施即代码的实践中,配置文件管理是核心能力。无论是应用部署、容器编排还是 CI/CD 流水线,JSON 和 YAML 格式的配置文件无处不在。PowerShell 原生支持 JSON 的读写与转换,配合 powershell-yaml 模块也能轻松处理 YAML,包括 Kubernetes 风格的多文档格式。

本文将从实际场景出发,逐步介绍如何用 PowerShell 完成 JSON 配置读取与修改、YAML 解析、Schema 验证、模板渲染以及环境配置切换。

读取 JSON 配置

日常运维中,我们经常需要从 JSON 文件中提取特定的配置项。PowerShell 的 ConvertFrom-Json 可以将 JSON 文本转换为对象,然后用属性访问语法直接取值。下面封装了一个通用函数,支持按层级路径读取指定区段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 读取 JSON 配置文件,支持按层级路径提取指定区段
function Get-JsonConfig {
param(
[Parameter(Mandatory)]
[string]$Path,

[string[]]$Sections
)

# 读取原始内容并转换为 PowerShell 对象
$json = Get-Content $Path -Raw | ConvertFrom-Json

# 如果指定了区段路径,逐层深入取值
if ($Sections) {
$result = $json
foreach ($section in $Sections) {
$result = $result.$section
}
return $result
}

return $json
}

假设我们有一个应用配置文件 appsettings.json,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"server": {
"host": "0.0.0.0",
"port": 8080
},
"database": {
"host": "db.internal",
"port": 5432,
"name": "app_prod"
},
"logging": {
"level": "INFO",
"path": "/var/log/app.log"
}
}

读取整个配置或某个区段:

1
2
3
4
5
6
7
# 读取完整配置
$config = Get-JsonConfig -Path ".\appsettings.json"
$config.database.host

# 只读取数据库区段
$dbConfig = Get-JsonConfig -Path ".\appsettings.json" -Sections "database"
$dbConfig

执行结果示例:

1
2
3
4
5
db.internal

host port name
---- ---- ----
db.internal 5432 app_prod

修改 JSON 配置

读取只是第一步,更多时候我们需要修改配置项并写回文件。下面的函数支持用点号分隔的路径(如 database.host)来定位嵌套属性,并可选地在修改前备份原文件。

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
# 修改 JSON 配置,支持点号路径定位嵌套属性
function Set-JsonConfig {
param(
[Parameter(Mandatory)]
[string]$Path,

[Parameter(Mandatory)]
[hashtable]$Properties,

[switch]$Backup
)

# 可选:修改前备份原文件
if ($Backup) {
Copy-Item $Path "$Path.bak.$(Get-Date -Format 'yyyyMMddHHmmss')"
}

$json = Get-Content $Path -Raw | ConvertFrom-Json

# 遍历每个要修改的属性
foreach ($key in $Properties.Keys) {
$parts = $key -split '\.'
$obj = $json
# 逐层导航到父对象
for ($i = 0; $i -lt $parts.Count - 1; $i++) {
$obj = $obj.$($parts[$i])
}
# 设置最终属性值
$obj.$($parts[-1]) = $Properties[$key]
}

# 写回文件,深度设为 10 层以覆盖大多数嵌套结构
$json | ConvertTo-Json -Depth 10 | Set-Content $Path -Encoding UTF8
}

将数据库主机和日志级别修改为新值:

1
2
3
4
Set-JsonConfig -Path ".\appsettings.json" -Backup -Properties @{
"database.host" = "db-new.internal"
"logging.level" = "DEBUG"
}

执行结果示例:

1
2
3
4
5
6
7
# 原文件已备份为 appsettings.json.bak.20250402100000
# appsettings.json 中的值已更新
Get-Content .\appsettings.json | ConvertFrom-Json | Select-Object -ExpandProperty database

host port name
---- ---- ----
db-new.internal 5432 app_prod

读取 YAML 配置

YAML 在 Kubernetes、CI/CD 等场景中广泛使用。PowerShell 本身不支持 YAML,但 powershell-yaml 模块弥补了这个缺口。下面的函数封装了 YAML 读取逻辑,并在模块缺失时给出友好提示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 读取 YAML 配置文件(需先安装 powershell-yaml 模块)
function Get-YamlConfig {
param(
[Parameter(Mandatory)]
[string]$Path
)

# 检查模块是否已安装
if (-not (Get-Module -ListAvailable -Name powershell-yaml)) {
Write-Warning "需要安装 powershell-yaml 模块: Install-Module powershell-yaml -Scope CurrentUser"
return $null
}

Import-Module powershell-yaml
$content = Get-Content $Path -Raw
return $content | ConvertFrom-Yaml
}

使用方式很简单:

1
2
3
4
5
6
# 首次使用前安装模块(只需执行一次)
# Install-Module -Name powershell-yaml -Scope CurrentUser

# 读取 YAML 配置
$config = Get-YamlConfig -Path ".\config.yaml"
$config.server.host

执行结果示例:

1
0.0.0.0

解析 Kubernetes 多文档 YAML

Kubernetes 的资源清单文件通常包含多个文档,用 --- 分隔。标准的 YAML 解析器只处理第一个文档,因此需要先分割再逐一解析。下面的函数会将每个文档提取出 kindnamenamespace 等关键信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 读取 Kubernetes 风格的多文档 YAML,提取每个资源的元数据
function Get-YamlMultiDocument {
param([Parameter(Mandatory)][string]$Path)

Import-Module powershell-yaml
$content = Get-Content $Path -Raw

# 按 --- 分割多文档,过滤空行
$documents = $content -split '(?m)^---\s*$' | Where-Object { $_.Trim() }

foreach ($doc in $documents) {
$yaml = $doc | ConvertFrom-Yaml
[PSCustomObject]@{
Kind = $yaml.kind
Name = $yaml.metadata.name
Namespace = $yaml.metadata.namespace
Content = $yaml
}
}
}

假设有一个 deploy.yaml 包含 Namespace 和 Deployment 两个资源:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Namespace
metadata:
name: my-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-server
namespace: my-app
spec:
replicas: 3

解析结果如下:

1
Get-YamlMultiDocument -Path ".\deploy.yaml" | Format-Table Kind, Name, Namespace

执行结果示例:

1
2
3
4
Kind       Name        Namespace
---- ---- ---------
Namespace my-app
Deployment web-server my-app

Schema 验证

配置项的错误往往在运行时才暴露,提前做 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
# 对配置对象执行 Schema 验证
function Test-ConfigSchema {
param(
[Parameter(Mandatory)]
[PSCustomObject]$Config,

[Parameter(Mandatory)]
[hashtable]$Schema
)

$errors = @()

foreach ($rule in $Schema.GetEnumerator()) {
$key = $rule.Key
$constraints = $rule.Value
$value = $Config.$key

# 必填检查
if ($constraints.Required -and -not $value) {
$errors += "缺少必填字段: $key"
continue
}

if ($null -ne $value) {
# 类型检查
if ($constraints.Type -and $value.GetType().Name -ne $constraints.Type) {
$errors += "$key 类型错误: 期望 $($constraints.Type), 实际 $($value.GetType().Name)"
}
# 最小长度检查
if ($constraints.MinLength -and $value.Length -lt $constraints.MinLength) {
$errors += "$key 长度不足: 最小 $($constraints.MinLength)"
}
# 枚举值检查
if ($constraints.AllowedValues -and $value -notin $constraints.AllowedValues) {
$errors += "$key 值非法: $value, 允许值: $($constraints.AllowedValues -join ', ')"
}
# 正则匹配检查
if ($constraints.Pattern -and $value -notmatch $constraints.Pattern) {
$errors += "$key 格式不匹配: $($constraints.Pattern)"
}
}
}

if ($errors) {
Write-Host "配置验证失败:" -ForegroundColor Red
$errors | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
return $false
}

Write-Host "配置验证通过" -ForegroundColor Green
return $true
}

定义验证规则并执行检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 定义 Schema 规则
$schema = @{
serverName = @{ Required = $true; Type = "String"; MinLength = 3 }
port = @{ Required = $true; Type = "Int32" }
environment = @{ Required = $true; AllowedValues = @("dev", "staging", "prod") }
database = @{ Required = $true; Pattern = "^[a-z][a-z0-9_]+$" }
}

# 构造配置对象(这里故意写错 environment 值)
$config = [PSCustomObject]@{
serverName = "prod-sql-01"
port = 1433
environment = "production"
database = "app_db"
}

Test-ConfigSchema -Config $config -Schema $schema

执行结果示例:

1
2
3
配置验证失败:
- environment 值非法: production, 允许值: dev, staging, prod
False

模板渲染

在管理 Nginx、HAProxy 等配置时,硬编码不利于多环境复用。模板渲染可以将占位符替换为实际值,还支持条件块来控制内容是否输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 渲染配置模板,支持变量替换和条件块
function Resolve-ConfigTemplate {
param(
[Parameter(Mandatory)]
[string]$TemplatePath,

[Parameter(Mandatory)]
[hashtable]$Variables,

[string]$OutputPath
)

$template = Get-Content $TemplatePath -Raw

# 替换 {{变量名}} 占位符
foreach ($var in $Variables.GetEnumerator()) {
$template = $template -replace "\{\{$($var.Key)\}\}", $var.Value
}

# 处理条件块 {{#if VAR}}...{{/if}}
$template = [regex]::Replace(
$template,
'\{\{#if\s+(\w+)\}\}(.*?)\{\{/if\}\}',
{
param($m)
if ($Variables[$m.Groups[1].Value]) { $m.Groups[2].Value } else { "" }
},
[System.Text.RegularExpressions.RegexOptions]::Singleline
)

# 输出到文件或返回渲染结果
if ($OutputPath) {
$template | Set-Content $OutputPath -Encoding UTF8
Write-Host "配置已渲染: $OutputPath"
}

return $template
}

准备一个 Nginx 配置模板:

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
# 创建 Nginx 配置模板
$template = @"
server {
listen {{PORT}};
server_name {{HOST}};
{{#if SSL}}listen 443 ssl;
ssl_certificate {{SSL_CERT}};{{/if}}
location / {
proxy_pass http://{{BACKEND}};
}
}
"@

$template | Set-Content "$env:TEMP\nginx.tpl"

# 渲染模板:启用 SSL 的生产环境
Resolve-ConfigTemplate -TemplatePath "$env:TEMP\nginx.tpl" `
-Variables @{
PORT = "8080"
HOST = "app.example.com"
SSL = $true
SSL_CERT = "/etc/ssl/cert.pem"
BACKEND = "127.0.0.1:3000"
} `
-OutputPath ".\nginx.conf"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
配置已渲染: .\nginx.conf

# 生成的 nginx.conf 内容:
server {
listen 8080;
server_name app.example.com;
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
location / {
proxy_pass http://127.0.0.1:3000;
}
}

环境配置切换

在不同环境(开发、预发布、生产)之间切换时,需要加载对应的配置并注入到环境变量中。下面的函数根据环境名称查找对应配置文件,将所有配置项导出为以 APP_ 为前缀的环境变量。

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
# 切换应用环境,加载对应配置并导出为环境变量
function Switch-AppEnvironment {
param(
[Parameter(Mandatory)]
[ValidateSet("dev", "staging", "prod")]
[string]$Environment,

[string]$ConfigDir = ".\config"
)

# 查找对应环境的配置文件
$configFile = Join-Path $ConfigDir "$Environment.json"

if (-not (Test-Path $configFile)) {
throw "配置文件不存在: $configFile"
}

$config = Get-JsonConfig -Path $configFile

# 将每个配置项导出为环境变量
foreach ($prop in $config.PSObject.Properties) {
[Environment]::SetEnvironmentVariable(
"APP_$($prop.Name.ToUpper())", $prop.Value, "Process"
)
}

# 写入当前环境标记
[Environment]::SetEnvironmentVariable("APP_ENV", $Environment, "Process")

Write-Host "已切换到 $Environment 环境" -ForegroundColor Green
return $config
}

在开发环境与生产环境之间切换:

1
2
3
4
5
6
# 切换到开发环境
Switch-AppEnvironment -Environment dev -ConfigDir ".\config"

# 查看加载的环境变量
$env:APP_ENV
$env:APP_SERVER_HOST

执行结果示例:

1
2
3
4
已切换到 dev 环境

dev
127.0.0.1

管理配置文件时,建议将敏感信息(密码、密钥)从配置文件中分离,使用环境变量或密钥管理服务替代。模板渲染前务必做 Schema 验证,避免无效配置上线。通过本文介绍的工具函数,你可以构建一套完整的配置管理流程:读取配置、Schema 校验、模板渲染、环境切换,覆盖从开发到生产的全链路。