构建精确变更集

ShellBeginner
立即练习

介绍

参加人数增加后,Field Notes 批准了两项工作坊房间变更。多个场次标题和房间名称重复,因此安全的计划需要精确身份、批准范围和足够的目标容量。

你将预览一个可工作的简单变更,再构建可复用的 PowerShell 计划验证器。本实验以前置 PowerShell for Beginners 中的对象、JSON、筛选、参数和错误处理为基础;不要求 Bash 脚本知识。工具和原始合成活动数据已在独立工作区中准备好。时长为作者估计,包含阅读和普通错误处理。

构建完整的基于 ID 的计划

本步骤先预览一项准备好的变更,再构建可复用的验证器,在不改变排期的前提下保存完整业务计划。

进入 PowerShell 并预览一项请求

打开 Terminal,初始运行的是 Linux shell。下面的启动命令仅为本次 PowerShell 会话设置 UTF-8,不会改变全局 shell。env 将这些环境值传给程序。-NoLogo 隐藏启动横幅,-NoProfile 跳过个人启动脚本:

env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pwsh -NoLogo -NoProfile

等待以 PS 开头的提示符。后续所有命令都在 PowerShell 内运行。Set-Location 更改其工作目录:

Set-Location /home/labex/project/room-changes

准备好的 preview-one.ps1 只处理提供的单项演练请求。它支持 PowerShell 原生的 -WhatIf:描述动作而不执行。这是可工作的简单示例,还不是你要构建的可复用多请求验证器。./ 表示当前目录中的脚本:

./preview-one.ps1 -WhatIf

预期出现包含 Move s-a: r-old -> r-equal 和目标 ./rehearsal-only.json 的原生消息。ID 标明场次、原房间和拟迁入的房间。它没有移动所有同标题的场次。用 Get-Content -Raw 读取真实源 JSON,该命令将整个文件作为一个字符串返回:

Get-Content ./event.json -Raw

s-a 记录的 RoomId 仍为 r-old。Test-Path 只检查是否存在,不会创建任何内容:

Test-Path ./rehearsal-only.json
False

如果误漏了 -WhatIf,演练可能创建那个独立文件,但不会覆盖输入。Remove-Item -LiteralPath ./rehearsal-only.json 只删除演练输出;仅在该文件存在时使用,然后正确重试预览。-LiteralPath 按字面解释路径,不展开通配符。不要为了从错误中恢复而删除原始活动数据或请求。

构建工具前先读懂关系

场地方已经确认目标房间在这些时间可用。你要检查的是批准范围和容量,而不是解决日历冲突。读取请求映射,并用准备好的只读读取器查看真实原始排期:

Get-Content ./requests.json -Raw
./Show-RoomSchedule.ps1 -Path ./event.json

请求将 s-a 移至 r-equal,将 s-c 移至 r-large。s-a 和 s-b 的标题都是 Shared maps;本次请求中只有 s-a 获准。还有两个房间都叫 Studio。务必按 SessionId 和 RoomId 关联,不要使用显示名称。第一项请求的场次有 24 人,目标容量恰好为 24:相等是允许的。

event.json、requests.json、small-request.json、bad-request.json 及无关的 notes.txt 都是必须保留的原始文件,包括字节、文件模式、所有者和修改时间(mtime)。不要编辑准备好的工具。可以创建自己的脚本及本实验指定的新结果文件。课程路径及其整条目录链都是真实目录。输出必须是新文件,与输入不同,且其真实父目录必须已经存在;对于已有文件、目录或链接,即使是悬空链接,也必须拒绝且不改变它。操作时没有其他并发写入者。

提供的 JSON 是格式正确的 UTF-8 数据:一个包含 Rooms、Sessions 和 ApprovedSessionIds 的活动对象,以及一个非空请求数组,其中包含唯一的精确场次 ID 和目标房间 ID。ID 是区分大小写的字符串。人数和容量是非负整数值。未知元数据可以包含嵌套对象、有序数组、字符串、布尔值、null 及有界有限 JSON 数值,嵌套不超过 20 层。完全重复的对象键、格式不符的数据结构和任意恶意输入不在本课程范围内。

保存任何计划前验证每一项请求

你将创建 Build-RoomPlan.ps1,带有三个命名参数:EventPath、PlanPath 和 OutputPath。它必须通过这些参数和已安装命令处理其他提供的活动和 ID,不依赖相邻辅助文件。结果是按请求顺序排列的 JSON 数组。每行恰好包含 SessionId、OldRoomId、NewRoomId、Attendees 和 Capacity。这是描述当前对象关系的业务计划,不是自称完成工作的标记。它不会改变排期。下一个实验才会写出完整的更新后活动数据。

保存脚本前,先了解以下新增内容:

  • [CmdletBinding()] 将其声明为参数化高级脚本;[Parameter(Mandatory)] 要求提供参数,[string] 声明文本类型。try、catch、throw 和 exit 1 沿用前置错误处理实验。$ErrorActionPreference = 'Stop' 使命令错误终止当前操作。catch 用 Write-Error -ErrorAction Continue 显示原因,随后执行明确的 exit 1。
  • PowerShell 的 $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(...) 根据当前 PowerShell 目录解析输出。Split-Path -LiteralPath 返回其父路径。Get-Item -Force 也能发现隐藏对象和悬空链接。这里的 -ErrorAction SilentlyContinue 允许新输出按预期尚不存在。Test-Path -PathType Container 要求父目录存在。
  • ConvertFrom-Json -AsHashtable 将 JSON 对象保留为字典,包括仅大小写不同的未知键。-DateKind String 将类似日期的字符串保持为字符串,保留原始偏移量;准备好的 PowerShell 版本支持该选项。即使请求数组只有一个成员,-NoEnumerate 也会将其作为完整数组保留。
  • @(...) 将匹配项收集为数组,即使匹配数为零或一。-ceq 区分大小写比较。-ccontains 检查精确成员关系;-cnotcontains 是其反向判断。-ne 表示不等于,-gt 表示大于,-or 表示任一条件成立。-isnot [array] 拒绝非数组值。
  • $seen += ... 和 $changes += ... 向集合追加内容。未知 ID、未获准场次、重复请求或人数过多,都会在写入任何输出前抛出错误。-gt 允许容量恰好等于人数。
  • [pscustomobject]@{...} 收集计划的已知字段。ConvertTo-Json -InputObject $changes -Depth 5 序列化完整数组;足够的深度可避免将嵌套对象转换为显示字符串。JSON 属性顺序和缩进不重要,数组顺序则必须保留。布尔值不同于数值,而 1 和 1.0 这样的相等有限数值写法代表同一个 JSON 数值。
  • PowerShell 可以用 [Type]::Method(...) 调用 .NET 方法。[System.IO.File]::WriteAllText 将文本写入选定路径。[Environment]::NewLine 添加换行,[System.Text.UTF8Encoding]::new($false) 创建不带字节顺序标记的 UTF-8 编码。此方法不会自动遵守 WhatIf;下一个实验会将它放入明确的授权分支中。

用字面 here-string 保存完整脚本:@' 开始,独占一行的 '@ 结束。单引号使保存时保留脚本中的变量。Set-Content -Encoding utf8 只替换此脚本文件:

Set-Content -Path ./Build-RoomPlan.ps1 -Encoding utf8 -Value @'
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$EventPath,
    [Parameter(Mandatory)][string]$PlanPath,
    [Parameter(Mandatory)][string]$OutputPath
)
try {
    $ErrorActionPreference = 'Stop'
    $fullOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    if (Get-Item -LiteralPath $fullOutput -Force -ErrorAction SilentlyContinue) {
        throw 'Output already exists; choose a new path.'
    }
    $parent = Split-Path -LiteralPath $fullOutput
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        throw 'Output parent must already exist.'
    }
    $event = Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String
    $requests = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String -NoEnumerate
    if ($requests -isnot [array] -or $requests.Count -eq 0) { throw 'Plan must be a nonempty array.' }
    $changes = @()
    $seen = @()
    foreach ($request in $requests) {
        if ($seen -ccontains $request.SessionId) { throw "Duplicate session ID: $($request.SessionId)" }
        $seen += $request.SessionId
        $sessions = @($event.Sessions | Where-Object { $_.SessionId -ceq $request.SessionId })
        $rooms = @($event.Rooms | Where-Object { $_.RoomId -ceq $request.RoomId })
        if ($sessions.Count -ne 1) { throw "Unknown or ambiguous session ID: $($request.SessionId)" }
        if ($rooms.Count -ne 1) { throw "Unknown or ambiguous room ID: $($request.RoomId)" }
        if ($event.ApprovedSessionIds -cnotcontains $request.SessionId) { throw "Unapproved session ID: $($request.SessionId)" }
        if ($sessions[0].Attendees -gt $rooms[0].Capacity) { throw "Capacity exceeded: $($request.SessionId) -> $($request.RoomId)" }
        $changes += [pscustomobject]@{
            SessionId = $request.SessionId
            OldRoomId = $sessions[0].RoomId
            NewRoomId = $request.RoomId
            Attendees = $sessions[0].Attendees
            Capacity = $rooms[0].Capacity
        }
    }
    $json = ConvertTo-Json -InputObject $changes -Depth 5
    [System.IO.File]::WriteAllText($fullOutput, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
    Write-Output "Saved plan: $fullOutput"
}
catch {
    Write-Error ("Plan failed: {0}" -f $_.Exception.Message) -ErrorAction Continue
    exit 1
}
'@

在子 PowerShell 进程中运行验证器。-NonInteractive 禁止无人值守的提示,-File 指定脚本。子进程的 exit 1 不会关闭你的交互会话:

pwsh -NoLogo -NoProfile -NonInteractive -File ./Build-RoomPlan.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./validated-plan.json

$LASTEXITCODE 是最近一个外部程序的原生退出状态。立即读取:

$LASTEXITCODE
0

重新打开真实产物:

Get-Content ./validated-plan.json -Raw

其中包含两行请求:s-a、r-old、r-equal、24、24;以及 s-c、r-old、r-large、18、40。原始排期保持不变。已有输出会被明确拒绝,不会被静默替换。如果需要重新构建自己的计划,仅用 Remove-Item -LiteralPath ./validated-plan.json 删除 validated-plan.json,然后再次运行修正后的验证器。

将后续无效请求作为整体拒绝

bad-request.json 的第一项变更合法,后面却指定了未知目标房间。下一条命令预期失败:

pwsh -NoLogo -NoProfile -NonInteractive -File ./Build-RoomPlan.ps1 -EventPath ./event.json -PlanPath ./bad-request.json -OutputPath ./bad-plan-result.json

预期出现 Plan failed: 和未知房间的说明。立即读取原生结果:

$LASTEXITCODE
1
Test-Path ./bad-plan-result.json
False

前面合法的那一行没有作为部分计划发布。未知场次 ID、未批准 ID、重复请求和超出容量,也必须在写入前拒绝整次调用。请求文件为空或缺失,不是成功的空任务。

遇到普通路径拼写错误,用 Get-Location 和 Get-ChildItem 检查目录与名称,再用 Set-Location /home/labex/project/room-changes 返回。遇到脚本拼写错误,重新保存完整修正后的 here-string,仅替换自己的脚本。不要编辑源数据来让错误消失。最终检查会读取真实业务计划和原始属性,并在使用不同 ID 的隔离活动数据中运行你的原样脚本字节。它不会接受固定的初始答案列表,也不会更改你的实际排期。

总结

你使用了诚实的原生预览,通过精确 ID 关联场次与房间,检查批准和容量,并保存完整且已验证的业务计划。容量相等被接受,同标题记录保持区分,后续无效请求没有生成部分计划。原始活动数据和笔记保持不变;下一个实验将通过原生确认应用整个已验证计划。