不再使用 Write-Host ── PowerShell 的输出流与日志设计

· · PowerShell, Windows, 日志, 运维改善, 自动化, 脚本, 设计, 可维护性

「脚本确实在运行,但一旦失败就不知道发生了什么」── 这是投入运维的 PowerShell 脚本中最常见的咨询。追查原因,往往会发现同一种结构:处理状况只用 Write-Host 来表达,而在没有人盯着屏幕的夜间执行中,什么都没有留下

PowerShell 有 6 种输出流,各自面向的「读者」不同:是要返回值,还是要给人看,还是只在排查时才需要。带着这样的意识区分书写,就能让同一个脚本在交互执行时表现得友好,在无人值守执行时表现得机器可读。反之,如果把所有内容都塞进 Write-Host,只会增加既不能当值使用、也不会留在日志里的信息。

本文将整理 6 种输出流各自的角色、Write-Host 的正确定位、函数返回值被污染的机制、由调用方控制详细程度的方法,以及可用于运维的结构化日志形式。

1. 先说结论

  • PowerShell 的输出流共有 6 种。分别是成功(1)、错误(2)、警告(3)、详细(4)、调试(5)、信息(6),各自都能用编号进行重定向。*> 代表全部输出流。1
  • PowerShell 5.0 及以后,Write-Host 会写入信息流。因此可以用 6> 重定向,或用 -InformationVariable 捕获。在此之前既无法捕获,也无法抑制。2
  • Write-Host 专门用于「展示给人看」。不能用于返回值的场景。要传给管道的值应使用 Write-Output(或裸输出)。23
  • 函数会返回内部输出的所有对象。与是否使用 return 无关。不需要的输出,惯常做法是用 $null = ... 丢弃。4
  • 进度用 Write-Progress,处理经过用 Write-Verbose进度显示不是可以重定向的数据流。5
  • 加上 [CmdletBinding()] 的函数会自动获得 -Verbose-Debug-InformationAction 等通用参数。把详细程度的决定权交给调用方才是正确做法。67
  • 默认值值得记住。$VerbosePreference$DebugPreference$InformationPreference 为 SilentlyContinue,$WarningPreference$ErrorActionPreference 为 Continue。8
  • 想事后解析的日志要结构化(1 行 1 个 JSON)。想还原屏幕显示效果的话,可以并用 Start-Transcript9

本文的前提版本与 5.1 中的差异

在信息系统部门的现场,Windows PowerShell 5.1 依然是主流。本文先整理清楚各处描述分别以哪个版本为前提。

描述 前提版本 在 Windows PowerShell 5.1 中
6 种输出流及带编号的重定向、*> 无论 5.1 还是 PowerShell 7 都相同 原样适用1
Write-Host 写入信息流(6 号)(可用 6>-InformationVariable 捕获) PowerShell 5.0 及以后(第 3 章) 5.1 属于 5.0 及以后的版本,原样适用2
Write-Information-InformationAction / -InformationVariable PowerShell 5.0 及以后(第 4 章) 原样适用7
函数返回内部的全部输出、用 $null = ... 抑制 与版本无关(第 5 章) 原样适用4
外部命令(原生命令)的 2>&1 处理方式 PowerShell 7.4 及以后的行为说明(第 6 章) 不适用。用类型来区分外部命令输出的写法,请务必在实际运行的版本上确认
-ProgressAction 通用参数控制 Write-Progress PowerShell 7.4 及以后5 无法使用。请用 $ProgressPreference 控制(第 8 章按此方式撰写)
文末分发的示例代码 PowerShell 7.6 中实际运行验证(Pester 14 项)

简而言之,第 2 章至第 5 章及第 7 章在 5.1 中同样可以原样使用。需要注意版本差异的,仅有外部命令的重定向(第 6 章)与进度显示的控制方式(第 8 章)这两处。

2. 六种输出流及其去向

首先用一张图梳理清楚,哪个写入命令的内容会送达何处。

Write-Output / 裸输出Write-Error / Write-WarningWrite-Verbose / Write-DebugWrite-Information / Write-Host1 成功流2 错误 / 3 警告 / 4 详细5 调试 / 6 信息传递给后续处理管道・赋值给变量显示在屏幕上是否默认显示取决于环境设置变量可保存到文件带编号的重定向・用于捕获的变量

只有成功流会传递给后续处理。其余 5 种要么显示在屏幕上,要么通过重定向或 -*Variable 捕获,都不会进入管道。如果在左侧的分支(用哪个命令写)上选错,一定会以「值传不到」或「日志里没留下」的形式表现出来。编号用于像 3> warnings.log 这样指定重定向时使用(第 6 章)。

# 写入命令 目标读者 默认环境设置
1 成功(Success) Write-Output / 裸输出 后续处理(管道)
2 错误(Error) Write-Error / 抛出 人 + 监控 Continue
3 警告(Warning) Write-Warning Continue
4 详细(Verbose) Write-Verbose 正在排查的人 SilentlyContinue
5 调试(Debug) Write-Debug 开发者 SilentlyContinue
6 信息(Information) Write-Information / Write-Host 人 + 留存记录 SilentlyContinue

信息流的默认值是 SilentlyContinue,但 Write-Host 却会显示在屏幕上,这并不矛盾。只有 Write-Host 是例外,Microsoft Learn 明确写道:「$InformationPreference 环境设置变量与 -InformationAction 通用参数不会影响 Write-Host 的消息」。2 也就是说,Write-Information 默认不会显示,而 Write-Host 即使在默认情况下也会显示,能够抑制它的只有 -InformationAction Ignore6> 重定向。

这张表中最重要的是第一行。成功流不是用来写「给人看的消息」的地方。如果在这里写入面向人的字符串,那么一旦用 | 把该函数接入管道,意料之外的字符串就会流入后续处理。

function Get-KsTargetFile {
    Write-Output "正在检索目标..."   # 【NG】混入返回值
    Get-ChildItem -Path $path -Filter '*.csv'
}

# 调用方期望得到的是 FileInfo 数组,结果开头却混入了一个字符串
$files = Get-KsTargetFile
$files[0].FullName    # → 空(因为开头是字符串)

正确的做法是,把进展报告发送到详细流或信息流。

function Get-KsTargetFile {
    [CmdletBinding()]
    param([string] $Path)

    Write-Verbose "正在检索目标: $Path"   # 仅在指定 -Verbose 时显示
    Get-ChildItem -Path $Path -Filter '*.csv'      # 返回值只有 FileInfo
}

3. Write-Host 是「恶」吗

曾经有一种「不要用 Write-Host」的说法广为流传,但如今的 PowerShell 情况已经不同了。PowerShell 5.0 及以后,Write-Host 被实现为向信息流(6 号)写入,可以用 6> 重定向,或用 -InformationVariable 捕获。2 「只能显示在屏幕上,事后完全无法获取」这种当年的批评已经不再适用。不过正如第 2 章所提到的,即便信息流的默认值是 SilentlyContinueWrite-Host 的显示也不会消失,因为它不受 $InformationPreference-InformationAction 的影响(唯一的例外是 -InformationAction Ignore,只有它能抑制 Write-Host 的输出)。「变得可以捕获」与「默认显示在屏幕上」这两点是可以同时成立的。2

话虽如此,它的适用场景仍然有限。

适合使用 Write-Host 的场景

  • 在交互式使用的工具中,想输出带颜色的标题或分隔符时(-ForegroundColor
  • 想向用户说明「这个脚本接下来要做什么」时
  • 目的不是处理的值,而是经过装饰的显示本身时

不应使用 Write-Host 的场景

  • 想把值作为函数的返回值传递时(→ Write-Output
  • 想留存日后要解析的运维日志时(→ 结构化日志,或 Write-Information
  • 想由调用方切换显示・不显示时(→ Write-Verbose

无人值守执行的脚本,本来就没有显示对象。只用 Write-Host 来表达状况的脚本,一旦被任务计划程序执行,就会立刻变成一个「什么都看不出来的脚本」。这正是本文标题的含义所在。

4. 把控制权交给调用方 ── [CmdletBinding()] 与通用参数

Write-Verbose 的真正价值在于,是否显示可以由调用方决定。只需给函数加上 [CmdletBinding()],就能自动获得 -Verbose-Debug-WarningAction-InformationAction-ErrorAction 等通用参数。67

function Invoke-KsImport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $CsvPath
    )

    Write-Verbose "开始导入: $CsvPath"          # 默认不显示
    Write-Information "导入: $CsvPath" -Tags 'KsImport'   # 默认不显示(可以捕获)

    $rows = Import-Csv -Path $CsvPath
    if ($rows.Count -eq 0) {
        Write-Warning "$CsvPath 中没有可导入的对象"   # 默认会显示
        return
    }

    Write-Verbose "将处理 $($rows.Count) 条记录"
    $rows | ForEach-Object { ConvertTo-KsRecord $_ }         # 返回值仅此而已
}

# 通常执行: 只显示警告,返回值是记录
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv'

# 排查时: 也想看到经过
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv' -Verbose

# 只把信息流捕获到变量,之后写入日志文件
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv' -InformationVariable info
$info | ForEach-Object { $_.MessageData } | Add-Content -Path $logPath

经常能见到自建 $LogLevel 变量、再用 if 语句分支的实现,但采用标准机制会更简短,也更能让他人理解意图。因为「加上 -Verbose 就会显示详细信息」是所有使用 PowerShell 的人的共同认知。

另外,$VerbosePreference 等环境设置变量会作用于当前作用域及其子作用域。8-Verbose 调用函数时,函数内部调用的 cmdlet 也会开始输出详细信息,因此有时输出量会比预想的更多。

5. 函数返回值被污染 ── PowerShell 特有的陷阱

PowerShell 的函数即使没有显式的 return,也会返回内部输出的所有对象4 这既是一项强大的特性,同时也是最容易出事故的部分。

function New-KsWorkFolder {
    param([string] $Path)

    New-Item -Path $Path -ItemType Directory   # 【陷阱】DirectoryInfo 混入返回值

    $list = [System.Collections.Generic.List[string]]::new()
    $list.Add('log')                            # 【陷阱2】.Add() 是 void,没有实际影响
    $sb = [System.Text.StringBuilder]::new()
    $sb.Append('x')                             # 【陷阱3】StringBuilder 自身被返回

    return $Path
}

$p = New-KsWorkFolder -Path 'D:\work'   # $p 会变成一个含 3 个元素的数组(DirectoryInfo、StringBuilder、string)

应对方法是「丢弃不需要的输出」。写法有三种,$null = ... 最为轻量

$null = New-Item -Path $Path -ItemType Directory   # 推荐
New-Item -Path $Path -ItemType Directory | Out-Null # 多了管道,会稍慢一些
[void] $sb.Append('x')                             # 常用于 .NET 方法

只要写测试,这种行为一下子就能发现。「固定返回值的形态」这类测试的重要性,在《用 Pester 完善 PowerShell 测试》中有讨论。

6. 重定向与捕获

输出流可以按编号分别重定向。1

.\Invoke-NightlyExport.ps1 3> warnings.log            # 只把警告输出到单独的文件
.\Invoke-NightlyExport.ps1 4>&1 | Tee-Object -FilePath run.log   # 把详细信息合流到成功流
.\Invoke-NightlyExport.ps1 *> all.log                 # 把全部输出流汇总到一个文件
.\Invoke-NightlyExport.ps1 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }

> 是覆盖写入,>> 是追加写入。不过要注意,用重定向合流后,类型会混在一起。像上面的例子那样,把 PowerShell 脚本或函数的错误流合流时,其元素仍然是 ErrorRecord,因此可以像上面那样按类型区分。

外部程序(原生命令)的 2>&1 情况则不同。PowerShell 7.4 及以后,重定向输出会被当作字节流处理,合流后会变成字符串,因此按 ErrorRecord 区分不再有效。如果想区分外部命令的 stdout/stderr,请不要合流,分开接收(参见《正确从 PowerShell 调用外部 exe》)。

代码是否真的按预期分流,最可靠的方法是自己动手试一次。用一段同时输出成功流和警告流的简短代码就能确认。

# 只把警告输出到单独的文件。屏幕上只会留下 '数据'
& { Write-Output '数据'; Write-Warning '注意事项' } 3> warnings.log
Get-Content warnings.log     # 里面是警告消息,不会包含 '数据'

# 把全部输出流汇总到一个文件
& { Write-Output '数据'; Write-Warning '注意事项'; Write-Verbose '详细信息' -Verbose } *> all.log
Get-Content all.log          # 3 项输出会一起写入

如果加了 3>warnings.log 仍是空的,说明那条消息并没有写入警告流(如果是用 Write-Host 写的,就要用 6>)。排查「本该输出到日志里的内容却没有出现」这类问题,从这两行开始是最快的

另外,*> 虽然省事,但一旦汇总到一起,之后再想按流重新拆分就会变得困难。如果想用程序做机械化统计,请按流分别输出到不同文件,或者采用下一章介绍的结构化日志。

如果想把执行证据原样整体保留下来,Start-Transcript 很省事。它会把会话的命令与输出记录成文本,事后就能还原「当时屏幕上显示了什么」。9

Start-Transcript -Path "C:\Logs\export_$(Get-Date -f yyyyMMdd_HHmmss).log" -Append
try   { Invoke-KsExport }
finally { Stop-Transcript }

7. 做成可以事后解析的日志 ── 结构化日志

给人看的日志,和给机器统计的日志是两码事。当你想知道「上个月这个错误出现了多少次」时,自由格式的文本就会变成和 grep 的耐力赛。如果按 1 行 1 个 JSON(JSON Lines)来写,统计单靠 PowerShell 就能完成。

function Write-KsLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateSet('INFO','WARN','ERROR')] [string] $Level,
        [Parameter(Mandatory)] [string] $Message,
        [hashtable] $Data,
        [string] $Path = $script:KsLogPath
    )

    $entry = [ordered]@{
        ts      = (Get-Date).ToString('o')    # ISO 8601,便于排序与比对
        level   = $Level
        message = $Message
        script  = $MyInvocation.ScriptName
        host    = $env:COMPUTERNAME
        user    = $env:USERNAME
    }
    # 附加信息放进 data 之下。如果混在顶层,一旦调用方传入了
    # level 或 user 之类的键,就会覆盖基本项目
    if ($Data) { $entry['data'] = $Data }

    # 用 -Compress 压成 1 行。追加写入用 Add-Content(UTF-8)
    $entry | ConvertTo-Json -Compress -Depth 5 | Add-Content -Path $Path -Encoding utf8

    # 面向人的显示走标准机制(即使显示在屏幕上,也不会返回值)
    switch ($Level) {
        # 不指定 -ErrorAction。如果在这里固定下来,调用方就无法
        # 通过 -ErrorAction Stop 把它变成终止错误
        'ERROR' { Write-Error   $Message }
        'WARN'  { Write-Warning $Message }
        default { Write-Verbose $Message }
    }
}

# 使用示例
Write-KsLog -Level INFO -Message '导入完成' -Data @{ rows = 1250; file = 'orders.csv'; ms = 4210 }
# → {"ts":"...","level":"INFO","message":"导入完成","script":"...","host":"...","user":"...",
#    "data":{"rows":1250,"file":"orders.csv","ms":4210}}

统计代码如下。

Get-Content 'C:\Logs\ks.log' |
    ForEach-Object { $_ | ConvertFrom-Json } |
    Where-Object { $_.level -eq 'ERROR' -and [datetime]$_.ts -ge (Get-Date).AddDays(-30) } |
    Group-Object message | Sort-Object Count -Descending | Select-Object Count, Name

也可以选择接入 Windows 的标准日志基础设施(事件日志・ETW)。如果考虑与监控工具联动,或从多台机器统一收集,那种方式更有优势。设计上的比较整理在《Windows 事件日志・ETW 与结构化日志》中,日志文件的世代管理整理在《PowerShell 脚本应用 ── 日志排查・归档・报表化》中。

8. 进度显示的处理

Write-Progress 使用的是宿主的进度显示功能,并不是可以重定向的数据流5 也就是说无法留存到日志中。无人值守执行时既没有显示对象,视环境不同,进度更新的开销有时也不可忽视。

# 在无人值守脚本的开头停止进度显示
$ProgressPreference = 'SilentlyContinue'

如果想把进度留存到运维日志中,实用的做法是只把节点信息写入详细流。

$i = 0
foreach ($row in $rows) {
    $i++
    if ($i % 100 -eq 0) { Write-Verbose "已完成 $i / $($rows.Count) 件" }
    ...
}

9. 实务定式(判断表)

想输出的信息 使用的方式 理由
传给后续处理的值 Write-Output / 裸输出 成功流专用于数据3
处理经过(只在排查时想看) Write-Verbose 由调用方通过 -Verbose 控制7
想作为运维记录的事件 Write-Information + 结构化日志 可用 -InformationVariable 捕获2
交互式工具的装饰性显示 Write-Host 经由信息流,因此也可以捕获2
在预期之内但需要注意的事件 Write-Warning 默认会显示,可用 -WarningVariable 捕获8
失败 Write-Error / throw 错误处理请参见专门文章
开发中的内部状态 Write-Debug 仅在加了 -Debug7
进度 Write-Progress(仅限交互时) 不会留存到日志中。无人值守执行时应停止5
整体保留执行证据 Start-Transcript 作为自建日志的保险并用9

10. 总结

  • PowerShell 的输出分为 6 种流。成功流专用于数据,混入面向人的消息会破坏返回值。
  • PowerShell 5.0 及以后的 Write-Host 因写入信息流而可以被捕获,但不能用于返回值的场景,也不能用于运维日志的场景。
  • 函数会返回内部输出的全部内容。不需要的输出,惯常做法是用 $null = ... 丢弃。
  • 加上 [CmdletBinding()] 并使用 Write-Verbose / Write-Information,就能把详细程度的控制权交给调用方。这比自建日志级别变量更简短,也更能传达意图。
  • 想事后统计的日志,要做成 1 行 1 个 JSON 的结构化日志。如果目的是还原屏幕显示,可以并用 Start-Transcript
  • 进度显示不是数据流,因此不会留存到日志中。无人值守执行时,用 $ProgressPreference = 'SilentlyContinue' 停止它是实用的做法。

示例代码下载

本文涉及的代码已整理成可以直接运行的形式提供下载。其中包含 1 行 1 个 JSON 的结构化日志,以及不污染返回值的函数写法。

下载示例代码(zip)

本文的示例已经在 PowerShell 7.6 中实际运行并验证过(Pester 14 项)。运行 zip 中包含的 Invoke-SampleTests.ps1,你也可以在自己的环境中重现相同的验证。

# 语法解析 + 静态分析 + Pester 测试
./Invoke-SampleTests.ps1

配置值(路径、服务器名、租户 ID 等)均为示例。请勿直接在生产环境中运行,请根据贵公司的环境自行替换。

相关文章

相关咨询领域

合同会社小村软件提供运维脚本日志设计的复查、消除「不知道失败原因」的状态,以及整备可连接监控与统计的日志基础设施等服务。

参考链接

  1. Microsoft Learn,about_Redirection。关于 PowerShell 拥有成功・错误・警告・详细・调试・信息各个输出流并分别用编号标识,用 > >> 重定向到文件,用 n>&1 合流到其他流,以及用 *> 重定向全部流。  2 3

  2. Microsoft Learn,Write-Host。关于 PowerShell 5.0 及以后的 Write-Host 作为 Write-Information 的包装器向信息流输出,从而可以用 6> 重定向;$InformationPreference 环境设置变量与 -InformationAction 通用参数不会影响 Write-Host 的消息(例外是 -InformationAction Ignore),因此默认也会显示在屏幕上;-ForegroundColor / -BackgroundColor 的装饰效果;以及输出不会传递给管道。相关地,Write-Information 涉及向信息流的显式写入与用 -Tags 进行分类。  2 3 4 5 6 7 8

  3. Microsoft Learn,Write-Output。关于把对象发送到成功流(管道),以及即使不显式调用,表达式的结果也会以同样方式输出。  2

  4. Microsoft Learn,about_Return。关于 PowerShell 的函数无论是否使用 return,都会把函数内输出的所有对象返回给调用方,以及 return 是一种在返回值的同时退出当前作用域的语法。  2 3

  5. Microsoft Learn,Write-Progress。关于把命令的进度作为宿主的进度显示输出,可以用 $ProgressPreference 控制显示,以及 PowerShell 7.4 及以后也可以用 -ProgressAction 通用参数控制。  2 3 4

  6. Microsoft Learn,about_Functions_CmdletBindingAttribute。关于加上 [CmdletBinding()] 属性的高级函数会像已编译的 cmdlet 一样运作,并自动获得通用参数。  2

  7. Microsoft Learn,about_CommonParameters。关于 -Verbose / -Debug / -WarningAction / -InformationAction / -ErrorAction 及对应的 -*Variable 参数的行为,以及与环境设置变量的关系。  2 3 4 5

  8. Microsoft Learn,about_Preference_Variables。关于 $VerbosePreference・$DebugPreference・$InformationPreference 的默认值为 SilentlyContinue,$WarningPreference 与 $ErrorActionPreference 的默认值为 Continue,用 $ProgressPreference 控制进度显示,以及这些设置作用于当前作用域及其子作用域。  2 3

  9. Microsoft Learn,Start-Transcript。关于把会话的命令与控制台输出记录到文本文件,用 -Append 追加写入,以及用 Stop-Transcript 停止记录。  2 3

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

不能使用 Write-Host 吗?
准确地说,并非禁止使用,而是用途有限。PowerShell 5.0 及以后的 Write-Host 会写入信息流(6 号),因此可以通过 6> 重定向或 -InformationVariable 捕获。不过它是一个默认始终显示在屏幕上的命令,无法用于将值传入管道。归纳一下:面向交互式工具、供人阅读的装饰性显示用 Write-Host;处理进度或补充信息用 Write-Verbose 或 Write-Information;要传给后续处理的值用 Write-Output(或裸输出)。
函数的返回值中混入了非预期的值。
这是因为 PowerShell 的函数即使没有显式的 return,也会返回函数内部输出的所有对象。像 New-Item 或 StringBuilder 的 .Append() 这类会返回值的命令与方法,如果不加处理地直接调用,其返回值就会流入成功流并到达调用方。反过来,像 List[T] 的 .Add() 这样返回值为 void 的方法不会输出任何内容,因此不需要抑制。应对方法是用 $null = ... 丢弃不需要的输出,或者加上 | Out-Null,或者用 [void] 进行类型转换。从性能角度看,$null = ... 是最轻量的写法。
希望在运行时切换脚本的详细日志。
请用 Write-Verbose 写出处理过程,并给函数加上 [CmdletBinding()]。这样一来,只有当调用方指定 -Verbose 时才会显示。如果想让它始终显示,可以在脚本开头设置 $VerbosePreference = 'Continue'。同样,Write-Debug 可以通过 -Debug、Write-Warning 可以通过 -WarningAction 由调用方控制。与自建日志级别变量相比,采用 PowerShell 的标准机制更简短,也更能让他人读懂意图。
包括详细日志与警告在内,如何把全部内容都保存到文件中?
根据用途不同有三种方法。如果只是想简单地把全部输出流落到文件,用 *> 重定向即可。如果想把执行证据(屏幕上显示的内容)原样保留下来,用 Start-Transcript 最省事。如果之后想通过程序解析,用自己的日志函数写出 1 行 1 个 JSON 的结构化日志最可靠,即便在这种情况下,把 transcript 作为保险并用也是有价值的。
Write-Progress 的显示内容能保存到日志文件里吗?
不能。因为进度显示是宿主的显示功能,与可重定向的数据流是分开处理的。无人值守执行时没有显示对象,所以请把进度和需要留存到日志中的信息区分开来考虑。在无人值守执行时,把 $ProgressPreference 设为 'SilentlyContinue' 以停止进度显示本身,视环境不同,有时还能带来明显的速度提升。如果想把进度留存到日志中,更实用的做法是用 Write-Verbose 只记录「100 件中已完成 50 件」这类节点信息。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表