适用于 PowerShell 5.1 及以上版本
在生产环境中管理 PowerShell 脚本时,Write-Host 和 Write-Output 这类简单输出方式很快就会暴露出不足:日志难以检索、无法按级别过滤、缺少上下文信息,更无法与 ELK、Splunk 等日志平台对接。当脚本数量增长到数十个、运行频率从手动变为定时任务时,缺乏结构化日志体系将成为运维效率的最大瓶颈。
结构化日志(Structured Logging)是解决这些问题的核心思路。它要求每条日志都携带固定格式的元数据——时间戳、级别、模块名、消息体以及自定义属性,并以 JSON 等机器可读格式输出。这样就能用 ConvertFrom-Json 在脚本内解析,也可以直接推送到日志平台进行全文检索和可视化分析。
本文将从零构建一个轻量但完整的结构化日志框架,涵盖日志级别控制、多目标输出(控制台 + 文件 + JSON 文件)、上下文自动注入、以及运行时的动态配置。
框架核心:日志条目数据结构 一切从定义日志条目的数据结构开始。我们用一个 PowerShell class 来承载每条日志的所有字段,确保输出格式始终一致。
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 enum LogLevel { Debug Information Warning Error Critical } class LogEntry { [DateTime ] $Timestamp [LogLevel ] $Level [string ] $Message [string ] $Module [hashtable ] $Properties [string ] $CorrelationId LogEntry( [LogLevel ] $Level , [string ] $Message , [string ] $Module , [hashtable ] $Properties , [string ] $CorrelationId ) { $this .Timestamp = Get-Date $this .Level = $Level $this .Message = $Message $this .Module = $Module $this .Properties = $Properties $this .CorrelationId = $CorrelationId } [hashtable ] ToHashtable () { $ht = [ordered ]@ { timestamp = $this .Timestamp.ToString('o' ) level = $this .Level.ToString() module = $this .Module message = $this .Message correlationId = $this .CorrelationId } foreach ($key in $this .Properties.Keys) { $ht [$key ] = $this .Properties[$key ] } return $ht } [string ] ToJson () { return $this .ToHashtable() | ConvertTo-Json -Compress } }
上面的代码定义了五个日志级别和一个 LogEntry 类。ToHashtable() 方法将日志转为有序哈希表,方便进一步处理;ToJson() 则输出紧凑的 JSON 字符串,可直接写入日志文件或发送到 HTTP 端点。注意这里使用 foreach 语句遍历属性字典,将自定义字段合并到输出中。
日志写入器:支持多目标输出 有了数据结构之后,需要实现写入器(Writer)来决定日志输出到哪里。下面定义三个写入器:控制台(带颜色)、纯文本文件、以及 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 class LoggerConfig { [LogLevel ] $MinimumLevel = [LogLevel ]::Information [string ] $LogDirectory = '.\logs' [string ] $ApplicationName = 'PowerShellApp' [string ] $CorrelationId = '' [System.Collections.Generic.List [scriptblock ]] $Writers LoggerConfig() { $this .Writers = [System.Collections.Generic.List [scriptblock ]]::new() if (-not $this .CorrelationId) { $this .CorrelationId = [guid ]::NewGuid().ToString('N' ).Substring(0 , 8 ) } } } function New-Logger { param ( [LogLevel ] $MinimumLevel = [LogLevel ]::Information, [string ] $LogDirectory = '.\logs' , [string ] $ApplicationName = 'PowerShellApp' ) $config = [LoggerConfig ]::new() $config .MinimumLevel = $MinimumLevel $config .LogDirectory = $LogDirectory $config .ApplicationName = $ApplicationName $config .Writers.Add({ param ($Entry ) $colorMap = @ { Debug = 'DarkGray' Information = 'White' Warning = 'Yellow' Error = 'Red' Critical = 'Magenta' } $color = $colorMap [$Entry .Level.ToString ()] Write-Host -ForegroundColor $color ( "[$ ($Entry .Timestamp.ToString('HH:mm:ss'))] " + "[$ ($Entry .Level)] [$ ($Entry .Module)] $ ($Entry .Message)" ) }.GetNewClosure()) $config .Writers.Add({ param ($Entry , $Config ) $dir = $Config .LogDirectory if (-not (Test-Path $dir )) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $dateStr = (Get-Date ).ToString('yyyyMMdd' ) $filePath = Join-Path $dir "$ ($Config .ApplicationName)-$dateStr .jsonl" $Entry .ToJson() | Add-Content -Path $filePath -Encoding UTF8 }.GetNewClosure()) return $config }
这段代码的核心是 LoggerConfig 类:它保存全局配置(最低级别、日志目录、应用名称、关联 ID)和一个写入器列表。New-Logger 函数创建配置实例并默认注册两个写入器——控制台和 JSONL 文件。JSONL(JSON Lines)格式每行一条 JSON 记录,非常适合日志场景,追加写入效率高,读取时逐行 ConvertFrom-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 59 60 61 62 63 64 65 66 67 68 function Send-Log { param ( [Parameter (Mandatory )] [LoggerConfig ] $Config , [Parameter (Mandatory )] [LogLevel ] $Level , [Parameter (Mandatory )] [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {} ) if ($Level -lt $Config .MinimumLevel) { return } $Properties ['host' ] = $env:COMPUTERNAME $Properties ['user' ] = $env:USERNAME $Properties ['psVersion' ] = $PSVersionTable .PSVersion.ToString() $entry = [LogEntry ]::new( $Level , $Message , $Module , $Properties , $Config .CorrelationId ) foreach ($writer in $Config .Writers) { try { & $writer $entry $Config } catch { Write-Warning "日志写入器执行失败: $ ($_ .Exception.Message)" } } } function Write-LogDebug { param ([LoggerConfig ] $Config , [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {}) Send-Log -Config $Config -Level Debug -Message $Message -Module $Module -Properties $Properties } function Write-LogInfo { param ([LoggerConfig ] $Config , [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {}) Send-Log -Config $Config -Level Information -Message $Message -Module $Module -Properties $Properties } function Write-LogWarning { param ([LoggerConfig ] $Config , [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {}) Send-Log -Config $Config -Level Warning -Message $Message -Module $Module -Properties $Properties } function Write-LogError { param ([LoggerConfig ] $Config , [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {}) Send-Log -Config $Config -Level Error -Message $Message -Module $Module -Properties $Properties } function Write-LogCritical { param ([LoggerConfig ] $Config , [string ] $Message , [string ] $Module = 'General' , [hashtable ] $Properties = @ {}) Send-Log -Config $Config -Level Critical -Message $Message -Module $Module -Properties $Properties }
Send-Log 在创建日志条目前自动注入主机名、用户名和 PowerShell 版本三个上下文字段。通过 foreach 语句遍历所有注册的写入器并逐一执行,单个写入器失败不会影响其他写入器。五个便捷函数封装了对应的日志级别,调用时无需手动指定 -Level 参数。
实战:在脚本中使用日志框架 下面模拟一个典型的文件处理脚本,展示日志框架的完整使用方式。
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 $logger = New-Logger -MinimumLevel Debug -LogDirectory '.\logs' -ApplicationName 'FileProcessor' Write-LogInfo -Config $logger -Message '文件处理任务启动' -Module 'Main' -Properties @ { taskType = 'BatchImport' sourcePath = 'D:\Data\Incoming' } $files = @ ('report-q3.csv' , 'inventory.json' , 'customers.xlsx' , 'orders-corrupt.bad' )foreach ($file in $files ) { Write-LogDebug -Config $logger -Message "开始处理文件: $file " -Module 'FileHandler' -Properties @ { fileName = $file fileSize = (Get-Random -Minimum 1024 -Maximum 10485760 ) } if ($file -match 'corrupt' ) { Write-LogError -Config $logger -Message "文件损坏,跳过处理: $file " -Module 'FileHandler' -Properties @ { fileName = $file errorCode = 'CORRUPT_HEADER' recoverable = $false } continue } Write-LogInfo -Config $logger -Message "文件处理完成: $file " -Module 'FileHandler' -Properties @ { fileName = $file rowsProcessed = (Get-Random -Minimum 100 -Maximum 5000 ) durationMs = (Get-Random -Minimum 50 -Maximum 2000 ) } } Write-LogWarning -Config $logger -Message '批次处理结束,存在跳过的文件' -Module 'Main' -Properties @ { totalFiles = $files .Count successCount = ($files | Where-Object { $_ -notmatch 'corrupt' }).Count skippedCount = 1 } Write-LogInfo -Config $logger -Message '文件处理任务完成' -Module 'Main'
模拟执行结果如下:
1 2 3 4 5 6 7 8 9 10 11 [14:30:01] [Information] [Main] 文件处理任务启动[14:30:01] [Debug] [FileHandler] 开始处理文件: report-q3.csv [14:30:01] [Information] [FileHandler] 文件处理完成: report-q3.csv [14:30:01] [Debug] [FileHandler] 开始处理文件: inventory.json [14:30:02] [Information] [FileHandler] 文件处理完成: inventory.json [14:30:02] [Debug] [FileHandler] 开始处理文件: customers.xlsx [14:30:02] [Information] [FileHandler] 文件处理完成: customers.xlsx [14:30:02] [Debug] [FileHandler] 开始处理文件: orders-corrupt.bad [14:30:02] [Error] [FileHandler] 文件损坏,跳过处理: orders-corrupt.bad [14:30:02] [Warning] [Main] 批次处理结束,存在跳过的文件[14:30:02] [Information] [Main] 文件处理任务完成
同时,在 .\logs 目录下会生成类似 FileProcessor-20250925.jsonl 的文件,每行是一条完整的 JSON 日志。下面是其中一行记录的格式化展示:
1 { "timestamp" : "2025-09-25T14:30:02.1234567+08:00" , "level" : "Error" , "module" : "FileHandler" , "message" : "文件损坏,跳过处理: orders-corrupt.bad" , "correlationId" : "a3f8c1d2" , "fileName" : "orders-corrupt.bad" , "errorCode" : "CORRUPT_HEADER" , "recoverable" : false , "host" : "WORKSTATION01" , "user" : "admin" , "psVersion" : "7.4.0" }
查询与分析结构化日志 结构化日志最大的优势在于可以用标准工具进行查询。下面的函数演示如何快速检索 JSONL 日志文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 function Search-LogArchive { param ( [Parameter (Mandatory )] [string ] $Path , [ValidateSet ('Debug' , 'Information' , 'Warning' , 'Error' , 'Critical' )] [string ] $Level , [string ] $Module , [string ] $MessagePattern , [DateTime ] $After , [int ] $Latest = 50 ) $records = [System.Collections.Generic.List [psobject ]]::new() $lines = Get-Content -Path $Path -Encoding UTF8 foreach ($line in $lines ) { if ([string ]::IsNullOrWhiteSpace($line )) { continue } try { $obj = $line | ConvertFrom-Json } catch { continue } if ($Level -and $obj .level -ne $Level ) { continue } if ($Module -and $obj .module -ne $Module ) { continue } if ($MessagePattern -and $obj .message -notmatch $MessagePattern ) { continue } if ($After -and [DateTime ]$obj .timestamp -lt $After ) { continue } $records .Add($obj ) } $results = $records | Sort-Object { $_ .timestamp } -Descending | Select-Object -First $Latest return $results } $errors = Search-LogArchive -Path '.\logs\FileProcessor-20250925.jsonl' -Level Errorforeach ($err in $errors ) { Write-Host "[$ ($err .timestamp)] $ ($err .message) (module=$ ($err .module), errorCode=$ ($err .errorCode))" }
查询结果示例:
1 [2025-09-25T14:30:02.1234567+08:00] 文件损坏,跳过处理: orders-corrupt.bad (module =FileHandler, errorCode =CORRUPT_HEADER)
Search-LogArchive 函数逐行读取 JSONL 文件,使用 ConvertFrom-Json 解析每条记录后按条件过滤。支持按级别、模块、消息模式和时间范围过滤,返回最近的 N 条匹配记录。由于使用 foreach 语句逐行处理,即使是几十 MB 的日志文件也能保持较低的内存占用。
动态调整日志级别 在生产环境中,经常需要在不重启服务的情况下调整日志级别来排查问题。下面实现一个简单的运行时级别切换机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function Set-LoggerLevel { param ( [Parameter (Mandatory )] [LoggerConfig ] $Config , [Parameter (Mandatory )] [LogLevel ] $Level ) $oldLevel = $Config .MinimumLevel $Config .MinimumLevel = $Level Send-Log -Config $Config -Level Information -Message "日志级别已变更" ` -Module 'Logger' -Properties @ { oldLevel = $oldLevel .ToString() newLevel = $Level .ToString() changedBy = $env:USERNAME } } $logger = New-Logger -MinimumLevel Information -ApplicationName 'WebMonitor' Write-LogInfo -Config $logger -Message '站点健康检查通过' -Module 'HealthCheck' Write-LogDebug -Config $logger -Message '这条不会输出' -Module 'HealthCheck' Set-LoggerLevel -Config $logger -Level DebugWrite-LogDebug -Config $logger -Message '现在 Debug 日志可见了' -Module 'HealthCheck' Set-LoggerLevel -Config $logger -Level Information
执行结果:
1 2 3 4 [14:35:10] [Information] [HealthCheck] 站点健康检查通过[14:35:10] [Information] [Logger] 日志级别已变更[14:35:10] [Debug] [HealthCheck] 现在 Debug 日志可见了[14:35:10] [Information] [Logger] 日志级别已变更
级别切换操作本身会被记录为一条 Information 日志,包含了变更前后的级别和操作人信息,方便审计追踪。这在多人共享的运维环境中尤为重要。
注意事项
关联 ID 的使用 :每个 LoggerConfig 实例会自动生成一个关联 ID(Correlation ID),在分布式系统中用于串联一次完整请求的所有日志。建议在脚本入口处将此 ID 传递给所有子模块和远程调用。
JSONL 与普通 JSON 的选择 :日志文件推荐使用 JSONL 格式(每行一条 JSON),而非 JSON 数组。JSONL 天然支持追加写入,不会因为进程中断导致文件格式损坏,也方便 Get-Content 逐行处理。
写入器的异常隔离 :Send-Log 中对每个写入器都使用了 try/catch,确保某个输出目标(如网络端点不可达)不会导致整个日志功能失效。生产环境中务必保留这一保护机制。
大日志文件的查询性能 :当日志文件超过 100MB 时,Get-Content 会消耗较多内存。建议按日期滚动日志文件,或使用 System.IO.StreamReader 进行流式读取。
线程安全注意事项 :PowerShell 的 foreach 语句和 Add-Content 在单一线程(单一 runspace)中是安全的。但如果使用 ForEach-Object -Parallel 或 Start-Job 等并发机制,需要额外加锁保护文件写入,否则会出现内容交错。
敏感信息过滤 :自动注入的上下文属性(主机名、用户名)可能包含敏感信息。在将日志推送到外部系统之前,建议实现一个脱敏写入器,对指定字段进行掩码处理。